commit 8a7ab3ac24d86c208a11c6d692d39659cf11dda6 Author: kcar Date: Fri Jul 11 13:21:49 2025 +0000 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..0657ca7 --- /dev/null +++ b/.env @@ -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 \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..ae29536 --- /dev/null +++ b/.env.development @@ -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 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..7a0f1a5 --- /dev/null +++ b/.env.production @@ -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 \ No newline at end of file diff --git a/App.test.tsx b/App.test.tsx new file mode 100644 index 0000000..414f0a3 --- /dev/null +++ b/App.test.tsx @@ -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(); + // ACT + // EXPECT + expect( + screen.getByRole('heading', { + level: 1, + } + )).toHaveTextContent('Hello World') + }); + it('Renders not found if invalid path', () => { + // ARRANGE + render( + + + + ); + // ACT + // EXPECT + expect( + screen.getByRole('heading', { + level: 1, + } + )).toHaveTextContent('Not Found') + }); +}); \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b25a9e1 --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..50c4a93 --- /dev/null +++ b/Dockerfile.dev @@ -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 \ No newline at end of file diff --git a/Dockerfile.storybook.macos.dev b/Dockerfile.storybook.macos.dev new file mode 100644 index 0000000..3a9f10d --- /dev/null +++ b/Dockerfile.storybook.macos.dev @@ -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"] \ No newline at end of file diff --git a/Dockerfile.storybook.macos.prod b/Dockerfile.storybook.macos.prod new file mode 100644 index 0000000..f8a2ca9 --- /dev/null +++ b/Dockerfile.storybook.macos.prod @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a30d7f1 --- /dev/null +++ b/README.md @@ -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 + ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..579769d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..de6e6cc --- /dev/null +++ b/eslint.config.js @@ -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' + } + } +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..cf7fff5 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Classroom Copilot + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ae381de --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..b7d2aa8 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + 'postcss-import': {}, + }, +}; \ No newline at end of file diff --git a/public/audioWorklet.js b/public/audioWorklet.js new file mode 100644 index 0000000..f42b160 --- /dev/null +++ b/public/audioWorklet.js @@ -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); \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..a08664f Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icons/icon-192x192-maskable.png b/public/icons/icon-192x192-maskable.png new file mode 100644 index 0000000..cadaf4a Binary files /dev/null and b/public/icons/icon-192x192-maskable.png differ diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png new file mode 100644 index 0000000..cadaf4a Binary files /dev/null and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-512x512-maskable.png b/public/icons/icon-512x512-maskable.png new file mode 100644 index 0000000..df8388c Binary files /dev/null and b/public/icons/icon-512x512-maskable.png differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png new file mode 100644 index 0000000..df8388c Binary files /dev/null and b/public/icons/icon-512x512.png differ diff --git a/public/icons/sticker-tool.svg b/public/icons/sticker-tool.svg new file mode 100644 index 0000000..5261504 --- /dev/null +++ b/public/icons/sticker-tool.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..74ec32a --- /dev/null +++ b/public/offline.html @@ -0,0 +1,70 @@ + + + + + + Offline - Classroom Copilot + + + +
+
📡
+

You're Offline

+

It looks like you've lost your internet connection. Don't worry - any work you've done has been saved locally.

+

Please check your connection and try again.

+ +
+ + + \ No newline at end of file diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..306a64f Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..36884a6 --- /dev/null +++ b/src/App.tsx @@ -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(() => ( + + + + + + + + + + + + + + + +)); + +// Add display name for better debugging +App.displayName = import.meta.env.VITE_APP_NAME; + +export default App; \ No newline at end of file diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx new file mode 100644 index 0000000..d744203 --- /dev/null +++ b/src/AppRoutes.tsx @@ -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 ( + +
+ +
+
+ ); + } + + return ( + + + {/* Public routes */} + : } + /> + } /> + } /> + } /> + + {/* Super Admin only routes */} + : + } + /> + + {/* Authentication only routes - only render if all contexts are initialized */} + {user && + isUserInitialized && + isNeoUserInitialized && + isNeoInstituteInitialized && ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + )} + + {/* Fallback route - use different NotFound pages based on auth state */} + : } /> + + + ); +}; + +export default AppRoutes; + diff --git a/src/axiosConfig.ts b/src/axiosConfig.ts new file mode 100644 index 0000000..36bf15d --- /dev/null +++ b/src/axiosConfig.ts @@ -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 }); \ No newline at end of file diff --git a/src/components/navigation/GraphNavigator.tsx b/src/components/navigation/GraphNavigator.tsx new file mode 100644 index 0000000..353a1ff --- /dev/null +++ b/src/components/navigation/GraphNavigator.tsx @@ -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); + const [historyMenuAnchor, setHistoryMenuAnchor] = useState(null); + const rootRef = useRef(null); + const [availableWidth, setAvailableWidth] = useState(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) => { + 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) => { + 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 ; + case 'calendar': + return ; + case 'teaching': + return ; + case 'school': + return ; + case 'department': + return ; + case 'class': + return ; + default: + return ; + } + }, []); + + const isDisabled = !isNeoUserInitialized || isLoading; + const { history } = context; + const canGoBack = history.currentIndex > 0; + const canGoForward = history.currentIndex < history.nodes.length - 1; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* History Menu */} + + {history.nodes.map((node, index) => ( + handleHistoryItemClick(index)} + selected={index === history.currentIndex} + > + + {getContextIcon(node.type)} + + + + ))} + + + + handleContextChange('profile' as BaseContext)} + startIcon={} + disabled={isDisabled || !userDbName} + > + {visibility.toggleLabels && Profile} + + handleContextChange('school' as BaseContext)} + startIcon={} + disabled={isDisabled || !workerDbName} + > + {visibility.toggleLabels && Institute} + + + + + + + + + + + + setContextMenuAnchor(null)} + > + {getContextItems().map(item => ( + handleContextSelect(item.id as BaseContext)} + disabled={isDisabled} + > + + + + + + ))} + + + ); +}; \ No newline at end of file diff --git a/src/components/navigation/extended/CalendarNavigation.tsx b/src/components/navigation/extended/CalendarNavigation.tsx new file mode 100644 index 0000000..4c54960 --- /dev/null +++ b/src/components/navigation/extended/CalendarNavigation.tsx @@ -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 = ({ 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 ( + + + + + + + + {currentCalendarNode && ( + + {currentCalendarNode.title} + + )} + + + + + + + + onViewChange('day')} + className={activeView === 'day' ? 'active' : ''} + > + + + onViewChange('week')} + className={activeView === 'week' ? 'active' : ''} + > + + + onViewChange('month')} + className={activeView === 'month' ? 'active' : ''} + > + + + onViewChange('year')} + className={activeView === 'year' ? 'active' : ''} + > + + + + + + } + onClick={handleToday} + disabled={!calendarStructure} + > + Today + + + + + ); +}; \ No newline at end of file diff --git a/src/components/navigation/extended/TeacherNavigation.tsx b/src/components/navigation/extended/TeacherNavigation.tsx new file mode 100644 index 0000000..6986def --- /dev/null +++ b/src/components/navigation/extended/TeacherNavigation.tsx @@ -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 = ({ 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 ( + + onViewChange(value as TeacherExtendedContext)} + variant="scrollable" + scrollButtons="auto" + isDarkMode={isDarkMode} + > + } + label="Overview" + /> + } + label="Timetable" + /> + } + label="Classes" + /> + } + label="Lessons" + /> + } + label="Journal" + /> + } + label="Planner" + /> + + + + + + + + + + {currentWorkerNode && ( + + {currentWorkerNode.title} + + )} + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/navigation/extended/UserNavigation.tsx b/src/components/navigation/extended/UserNavigation.tsx new file mode 100644 index 0000000..f4b6546 --- /dev/null +++ b/src/components/navigation/extended/UserNavigation.tsx @@ -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 = ({ activeView, onViewChange }) => { + const { currentWorkerNode } = useNeoUser(); + + return ( + + onViewChange(value as UserExtendedContext)} + variant="scrollable" + scrollButtons="auto" + > + } + label="Profile" + /> + } + label="Journal" + /> + } + label="Planner" + /> + + + + + {currentWorkerNode && ( + + {currentWorkerNode.label} + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/navigation/extended/index.ts b/src/components/navigation/extended/index.ts new file mode 100644 index 0000000..4a49214 --- /dev/null +++ b/src/components/navigation/extended/index.ts @@ -0,0 +1,3 @@ +export { CalendarNavigation } from './CalendarNavigation'; +export { TeacherNavigation } from './TeacherNavigation'; +export { UserNavigation } from './UserNavigation'; \ No newline at end of file diff --git a/src/config/navigationContexts.ts b/src/config/navigationContexts.ts new file mode 100644 index 0000000..7193023 --- /dev/null +++ b/src/config/navigationContexts.ts @@ -0,0 +1,211 @@ +import { ContextDefinition } from '../types/navigation'; + +export const NAVIGATION_CONTEXTS: Record = { + // 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' + } + ] + } +}; \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..cb041e9 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -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; + signOut: () => Promise; + clearError: () => void; +} + +export const AuthContext = createContext({ + 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(null); + const [user_role, setUserRole] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/src/contexts/NeoInstituteContext.tsx b/src/contexts/NeoInstituteContext.tsx new file mode 100644 index 0000000..560df47 --- /dev/null +++ b/src/contexts/NeoInstituteContext.tsx @@ -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({ + 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(null); + const [isLoading, setIsLoading] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(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 ( + + {children} + + ); +}; + +export const useNeoInstitute = () => useContext(NeoInstituteContext); diff --git a/src/contexts/NeoUserContext.tsx b/src/contexts/NeoUserContext.tsx new file mode 100644 index 0000000..6751948 --- /dev/null +++ b/src/contexts/NeoUserContext.tsx @@ -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; + navigateToWeek: (id: string) => Promise; + navigateToMonth: (id: string) => Promise; + navigateToYear: (id: string) => Promise; + currentCalendarNode: CalendarNode | null; + calendarStructure: CalendarStructure | null; + + // Worker Navigation + navigateToTimetable: (id: string) => Promise; + navigateToJournal: (id: string) => Promise; + navigateToPlanner: (id: string) => Promise; + navigateToClass: (id: string) => Promise; + navigateToLesson: (id: string) => Promise; + currentWorkerNode: WorkerNode | null; + workerStructure: WorkerStructure | null; +} + +const NeoUserContext = createContext({ + 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(null); + const [calendarNode] = useState(null); + const [workerNode] = useState(null); + const [currentCalendarNode, setCurrentCalendarNode] = useState(null); + const [currentWorkerNode, setCurrentWorkerNode] = useState(null); + const [calendarStructure] = useState(null); + const [workerStructure] = useState(null); + const [userDbName, setUserDbName] = useState(null); + const [workerDbName, setWorkerDbName] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(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 ( + + {children} + + ); +}; + +export const useNeoUser = () => useContext(NeoUserContext); diff --git a/src/contexts/TLDrawContext.tsx b/src/contexts/TLDrawContext.tsx new file mode 100644 index 0000000..337c3ee --- /dev/null +++ b/src/contexts/TLDrawContext.tsx @@ -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 | 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; + togglePresentationMode: (editor?: Editor) => void; + initializePreferences: (userId: string) => void; + setSharedStore: (store: SharedStoreService | null) => void; + setConnectionStatus: (status: 'online' | 'offline' | 'error') => void; +} + +const TLDrawContext = createContext({ + 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( + storageService.get(StorageKeys.TLDRAW_PREFERENCES) + ); + const [tldrawUserFilePath, setTldrawUserFilePathState] = useState( + storageService.get(StorageKeys.TLDRAW_FILE_PATH) + ); + const [localSnapshot, setLocalSnapshot] = useState | null>( + storageService.get(StorageKeys.LOCAL_SNAPSHOT) + ); + const [presentationMode, setPresentationMode] = useState( + storageService.get(StorageKeys.PRESENTATION_MODE) || false + ); + const [sharedStore, setSharedStore] = useState(null); + const [connectionStatus, setConnectionStatus] = useState<'online' | 'offline' | 'error'>('online'); + const [presentationService, setPresentationService] = useState(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 => { + 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 ( + + {children} + + ); +}; + +export const useTLDraw = () => useContext(TLDrawContext); diff --git a/src/contexts/UserContext.tsx b/src/contexts/UserContext.tsx new file mode 100644 index 0000000..3707774 --- /dev/null +++ b/src/contexts/UserContext.tsx @@ -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) => Promise; + updatePreferences: (updates: Partial) => Promise; + clearError: () => void; +} + +export const UserContext = createContext({ + 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(null); + const [profile, setProfile] = useState(null); + const [preferences, setPreferences] = useState({}); + const [loading, setLoading] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(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) => { + 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) => { + 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 ( + setError(null) + }} + > + {children} + + ); +} + +export const useUser = () => useContext(UserContext); diff --git a/src/debugConfig.ts b/src/debugConfig.ts new file mode 100644 index 0000000..cfc0206 --- /dev/null +++ b/src/debugConfig.ts @@ -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 = { + 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) { + 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 = { + 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; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..dd25258 --- /dev/null +++ b/src/index.css @@ -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; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..f0f64dd --- /dev/null +++ b/src/main.tsx @@ -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 ? ( + + + +) : ( + +); + +// 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 +); diff --git a/src/pages/.DS_Store b/src/pages/.DS_Store new file mode 100644 index 0000000..6ae2fb1 Binary files /dev/null and b/src/pages/.DS_Store differ diff --git a/src/pages/Header.tsx b/src/pages/Header.tsx new file mode 100644 index 0000000..a0c476e --- /dev/null +++ b/src/pages/Header.tsx @@ -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); + 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) => { + 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 ( + + + + navigate(isAuthenticated ? '/single-player' : '/')} + > + ClassroomCopilot + + + + + + + + + + + + + {isAuthenticated ? [ + // Development Tools Section + handleNavigation('/tldraw-dev')}> + + + + + , + handleNavigation('/dev')}> + + + + + , + , + + // Main Features Section + handleNavigation('/multiplayer')}> + + + + + , + handleNavigation('/calendar')}> + + + + + , + handleNavigation('/teacher-planner')}> + + + + + , + handleNavigation('/exam-marker')}> + + + + + , + , + + // Utilities Section + handleNavigation('/settings')}> + + + + + , + handleNavigation('/search')}> + + + + + , + + // Admin Section + ...(isAdmin ? [ + , + handleNavigation('/admin')}> + + + + + + ] : []), + + // Authentication Section + , + + + + + + + ] : [ + // Authentication Section for Non-authenticated Users + handleNavigation('/login')}> + + + + + , + , + handleSignupNavigation('teacher')}> + + + + + , + handleSignupNavigation('student')}> + + + + + + ]} + + + + + ); +}; + +export default Header; \ No newline at end of file diff --git a/src/pages/Layout.tsx b/src/pages/Layout.tsx new file mode 100644 index 0000000..bc312b4 --- /dev/null +++ b/src/pages/Layout.tsx @@ -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 = ({ children }) => { + return ( +
+
+
+ {children} +
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/src/pages/NotFoundPublic.tsx b/src/pages/NotFoundPublic.tsx new file mode 100644 index 0000000..f4b7459 --- /dev/null +++ b/src/pages/NotFoundPublic.tsx @@ -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 ( + + + + + 404 + + + Page Not Found + + + The page you're looking for doesn't exist or has been moved. + + + + + ); +} + +export default NotFoundPublic; \ No newline at end of file diff --git a/src/pages/auth/AuthCallback.tsx b/src/pages/auth/AuthCallback.tsx new file mode 100644 index 0000000..b80cd56 --- /dev/null +++ b/src/pages/auth/AuthCallback.tsx @@ -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 ( +
+
+
+

Redirecting...

+

Please wait while we redirect you to the login page...

+
+
+
+ ); +} + diff --git a/src/pages/auth/EmailLoginForm.tsx b/src/pages/auth/EmailLoginForm.tsx new file mode 100644 index 0000000..91738d6 --- /dev/null +++ b/src/pages/auth/EmailLoginForm.tsx @@ -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; +} + +export const EmailLoginForm: React.FC = ({ role, onSubmit }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(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 ( + + {error && ( + + {error} + + )} + + setEmail(e.target.value)} + margin="normal" + required + /> + + setPassword(e.target.value)} + margin="normal" + required + /> + + + + ); +}; \ No newline at end of file diff --git a/src/pages/auth/EmailSignupForm.tsx b/src/pages/auth/EmailSignupForm.tsx new file mode 100644 index 0000000..22b4a77 --- /dev/null +++ b/src/pages/auth/EmailSignupForm.tsx @@ -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; +} + +export const EmailSignupForm: React.FC = ({ + role, + onSubmit, +}) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(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 ( + + + setDisplayName(e.target.value)} + autoFocus + /> + + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + + setConfirmPassword(e.target.value)} + /> + + {error && {error}} + + + + + ); +}; diff --git a/src/pages/auth/adminPage.tsx b/src/pages/auth/adminPage.tsx new file mode 100644 index 0000000..fb23e0d --- /dev/null +++ b/src/pages/auth/adminPage.tsx @@ -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 ( + + ); +} + +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 ( + + Unauthorized Access + + + ); + } + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + return ( + + + Admin Dashboard + + + + + + + + + + + + + System Settings + {/* Add system settings components here */} + + + Database Management + {/* Add database management components here */} + + + User Management + {/* Add user management components here */} + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/pages/auth/loginPage.tsx b/src/pages/auth/loginPage.tsx new file mode 100644 index 0000000..7f53654 --- /dev/null +++ b/src/pages/auth/loginPage.tsx @@ -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(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 ( + + + ClassroomCopilot.ai + + + + Login + + + {error && ( + + {error} + + )} + + + + + + ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/src/pages/auth/signupPage.tsx b/src/pages/auth/signupPage.tsx new file mode 100644 index 0000000..7809c1e --- /dev/null +++ b/src/pages/auth/signupPage.tsx @@ -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 ( + + + ClassroomCopilot.ai + + + + {roleDisplay} Sign Up + + + + + + OR + + + + + + + + + ); +}; + +export default SignupPage; + diff --git a/src/pages/components/admin/SchoolUploadSection.tsx b/src/pages/components/admin/SchoolUploadSection.tsx new file mode 100644 index 0000000..890d46e --- /dev/null +++ b/src/pages/components/admin/SchoolUploadSection.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( + + + Create Schools + + + {error && ( + + {error} + + )} + + {success && ( + + {success} + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/pages/components/admin/TimetableUploadSection.tsx b/src/pages/components/admin/TimetableUploadSection.tsx new file mode 100644 index 0000000..8e347fd --- /dev/null +++ b/src/pages/components/admin/TimetableUploadSection.tsx @@ -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(null); + const [success, setSuccess] = useState(null); + const [isUploading, setIsUploading] = useState(false); + + const handleTimetableUpload = async (event: React.ChangeEvent) => { + 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 ( + + + Upload Teacher Timetable + + + {error && ( + + {error} + + )} + + {success && ( + + {success} + + )} + + + + {!workerNode && ( + + No teacher node found. Please ensure you have the correct permissions. + + )} + + ); +}; \ No newline at end of file diff --git a/src/pages/components/auth/LoginForm.tsx b/src/pages/components/auth/LoginForm.tsx new file mode 100644 index 0000000..4e7f9b8 --- /dev/null +++ b/src/pages/components/auth/LoginForm.tsx @@ -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; +} + +export const LoginForm: React.FC = ({ 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 ( +
+ setEmail(e.target.value)} + fullWidth + autoComplete="new-username" + /> + setPassword(e.target.value)} + fullWidth + autoComplete="new-password" + /> + + + ); +}; \ No newline at end of file diff --git a/src/pages/components/auth/SuperAdminSection.tsx b/src/pages/components/auth/SuperAdminSection.tsx new file mode 100644 index 0000000..0292208 --- /dev/null +++ b/src/pages/components/auth/SuperAdminSection.tsx @@ -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 ( + + ); +}; \ No newline at end of file diff --git a/src/pages/components/common/LoadingSpinner.tsx b/src/pages/components/common/LoadingSpinner.tsx new file mode 100644 index 0000000..e18f6ce --- /dev/null +++ b/src/pages/components/common/LoadingSpinner.tsx @@ -0,0 +1,12 @@ +import { CircularProgress, Box } from '@mui/material'; + +export const LoadingSpinner = () => ( + + + +); \ No newline at end of file diff --git a/src/pages/morphicPage.tsx b/src/pages/morphicPage.tsx new file mode 100644 index 0000000..04a4150 --- /dev/null +++ b/src/pages/morphicPage.tsx @@ -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 ( + + + Redirecting to Morphic... + + + + ); +}; + +export default MorphicPage; \ No newline at end of file diff --git a/src/pages/react-flow/teacherPlanner.tsx b/src/pages/react-flow/teacherPlanner.tsx new file mode 100644 index 0000000..acb06ba --- /dev/null +++ b/src/pages/react-flow/teacherPlanner.tsx @@ -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 ( +
+ + + + + +
+ ); +} diff --git a/src/pages/searxngPage.tsx b/src/pages/searxngPage.tsx new file mode 100644 index 0000000..15845d0 --- /dev/null +++ b/src/pages/searxngPage.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { HEADER_HEIGHT } from './Layout'; + +const SearxngPage: React.FC = () => { + return ( + +