Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
BIN
View File
Binary file not shown.
+305
View File
@@ -0,0 +1,305 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
AppBar,
Toolbar,
Typography,
IconButton,
Box,
useTheme,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Divider
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import {
Login as LoginIcon,
Logout as LogoutIcon,
School as TeacherIcon,
Person as StudentIcon,
Dashboard as TLDrawDevIcon,
Build as DevToolsIcon,
Groups as MultiplayerIcon,
CalendarToday as CalendarIcon,
Assignment as TeacherPlannerIcon,
AssignmentTurnedIn as ExamMarkerIcon,
Settings as SettingsIcon,
Search as SearchIcon,
AdminPanelSettings as AdminIcon
} from '@mui/icons-material';
import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator';
const Header: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { user, signOut } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
const showGraphNavigation = location.pathname === '/single-player';
// Update authentication state whenever user changes
useEffect(() => {
const newAuthState = !!user;
setIsAuthenticated(newAuthState);
logger.debug('user-context', '🔄 User state changed in header', {
hasUser: newAuthState,
userId: user?.id,
userEmail: user?.email,
userState: newAuthState ? 'logged-in' : 'logged-out',
isAdmin
});
}, [user, isAdmin]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleNavigation = (path: string) => {
navigate(path);
handleMenuClose();
};
const handleSignupNavigation = (role: 'teacher' | 'student') => {
navigate('/signup', { state: { role } });
handleMenuClose();
};
const handleSignOut = async () => {
try {
logger.debug('auth-service', '🔄 Signing out user', { userId: user?.id });
await signOut();
// Clear local state immediately
setIsAuthenticated(false);
setAnchorEl(null);
logger.debug('auth-service', '✅ User signed out');
} catch (error) {
logger.error('auth-service', '❌ Error signing out:', error);
console.error('Error signing out:', error);
}
};
return (
<AppBar
position="fixed"
sx={{
height: `${HEADER_HEIGHT}px`,
bgcolor: theme.palette.background.paper,
color: theme.palette.text.primary,
boxShadow: 1
}}
>
<Toolbar sx={{
display: 'flex',
justifyContent: 'space-between',
minHeight: `${HEADER_HEIGHT}px !important`,
height: `${HEADER_HEIGHT}px`,
gap: 2,
px: { xs: 1, sm: 2 }
}}>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
minWidth: { xs: 'auto', sm: '200px' }
}}>
<Typography
variant="h6"
component="div"
className="app-title"
sx={{
cursor: 'pointer',
color: theme.palette.text.primary,
'&:hover': {
color: theme.palette.primary.main
},
fontSize: { xs: '1rem', sm: '1.25rem' }
}}
onClick={() => navigate(isAuthenticated ? '/single-player' : '/')}
>
ClassroomCopilot
</Typography>
</Box>
<Box sx={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
justifyContent: 'center',
visibility: showGraphNavigation ? 'visible' : 'hidden',
width: {
xs: 'calc(100% - 160px)', // More space for menu and title
sm: 'calc(100% - 200px)', // Standard spacing
md: 'auto' // Full width on medium and up
},
maxWidth: '800px',
'& .navigation-controls': {
display: { xs: 'none', sm: 'flex' }
},
'& .context-section': {
display: { xs: 'none', md: 'flex' }
},
'& .context-toggle': {
display: 'flex' // Always show the profile/institute toggle
}
}}>
<GraphNavigator />
</Box>
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
minWidth: { xs: 'auto', sm: '200px' },
ml: 'auto'
}}>
<IconButton
className="menu-button"
color="inherit"
onClick={handleMenuOpen}
edge="end"
sx={{
'&:hover': {
bgcolor: theme.palette.action.hover
}
}}
>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
slotProps={{
paper: {
elevation: 3,
sx: {
bgcolor: theme.palette.background.paper,
color: theme.palette.text.primary,
minWidth: '240px'
}
}
}}
>
{isAuthenticated ? [
// Development Tools Section
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
<ListItemIcon>
<TLDrawDevIcon />
</ListItemIcon>
<ListItemText primary="TLDraw Dev" />
</MenuItem>,
<MenuItem key="dev" onClick={() => handleNavigation('/dev')}>
<ListItemIcon>
<DevToolsIcon />
</ListItemIcon>
<ListItemText primary="Dev Tools" />
</MenuItem>,
<Divider key="dev-divider" />,
// Main Features Section
<MenuItem key="multiplayer" onClick={() => handleNavigation('/multiplayer')}>
<ListItemIcon>
<MultiplayerIcon />
</ListItemIcon>
<ListItemText primary="Multiplayer" />
</MenuItem>,
<MenuItem key="calendar" onClick={() => handleNavigation('/calendar')}>
<ListItemIcon>
<CalendarIcon />
</ListItemIcon>
<ListItemText primary="Calendar" />
</MenuItem>,
<MenuItem key="planner" onClick={() => handleNavigation('/teacher-planner')}>
<ListItemIcon>
<TeacherPlannerIcon />
</ListItemIcon>
<ListItemText primary="Teacher Planner" />
</MenuItem>,
<MenuItem key="exam" onClick={() => handleNavigation('/exam-marker')}>
<ListItemIcon>
<ExamMarkerIcon />
</ListItemIcon>
<ListItemText primary="Exam Marker" />
</MenuItem>,
<Divider key="features-divider" />,
// Utilities Section
<MenuItem key="settings" onClick={() => handleNavigation('/settings')}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</MenuItem>,
<MenuItem key="search" onClick={() => handleNavigation('/search')}>
<ListItemIcon>
<SearchIcon />
</ListItemIcon>
<ListItemText primary="Search" />
</MenuItem>,
// Admin Section
...(isAdmin ? [
<Divider key="admin-divider" />,
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
<ListItemIcon>
<AdminIcon />
</ListItemIcon>
<ListItemText primary="Admin Dashboard" />
</MenuItem>
] : []),
// Authentication Section
<Divider key="auth-divider" />,
<MenuItem key="signout" onClick={handleSignOut}>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="Sign Out" />
</MenuItem>
] : [
// Authentication Section for Non-authenticated Users
<MenuItem key="signin" onClick={() => handleNavigation('/login')}>
<ListItemIcon>
<LoginIcon />
</ListItemIcon>
<ListItemText primary="Sign In" />
</MenuItem>,
<Divider key="signup-divider" />,
<MenuItem key="teacher-signup" onClick={() => handleSignupNavigation('teacher')}>
<ListItemIcon>
<TeacherIcon />
</ListItemIcon>
<ListItemText
primary="Sign up as Teacher"
secondary="Create a teacher account"
/>
</MenuItem>,
<MenuItem key="student-signup" onClick={() => handleSignupNavigation('student')}>
<ListItemIcon>
<StudentIcon />
</ListItemIcon>
<ListItemText
primary="Sign up as Student"
secondary="Create a student account"
/>
</MenuItem>
]}
</Menu>
</Box>
</Toolbar>
</AppBar>
);
};
export default Header;
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import Header from './Header';
interface LayoutProps {
children: React.ReactNode;
}
export const HEADER_HEIGHT = 40; // in pixels
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div>
<Header />
<main className="main-content" style={{
paddingTop: `${HEADER_HEIGHT}px`,
height: '100vh',
width: '100%'
}}>
{children}
</main>
</div>
);
};
export default Layout;
+51
View File
@@ -0,0 +1,51 @@
import { useNavigate } from "react-router-dom";
import { Box, Typography, Button, Container, useTheme } from "@mui/material";
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { logger } from '../debugConfig';
function NotFoundPublic() {
const theme = useTheme();
const navigate = useNavigate();
const handleReturn = () => {
logger.debug('not-found', '🔄 Public user navigating to home');
navigate('/');
};
return (
<Container maxWidth="sm">
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
textAlign: 'center',
gap: 3
}}
>
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
<Typography variant="h2" component="h1" gutterBottom>
404
</Typography>
<Typography variant="h5" gutterBottom>
Page Not Found
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
The page you're looking for doesn't exist or has been moved.
</Typography>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleReturn}
>
Return to Canvas
</Button>
</Box>
</Container>
);
}
export default NotFoundPublic;
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export default function AuthCallback() {
const navigate = useNavigate();
useEffect(() => {
// Redirect to login page since we're no longer supporting external authentication
navigate('/login');
}, [navigate]);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-6 shadow-lg">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Redirecting...</h2>
<p className="mt-2 text-sm text-gray-600">Please wait while we redirect you to the login page...</p>
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Alert } from '@mui/material';
import { EmailCredentials } from '../../services/auth/authService';
interface EmailLoginFormProps {
role: 'email_teacher' | 'email_student';
onSubmit: (credentials: EmailCredentials) => Promise<void>;
}
export const EmailLoginForm: React.FC<EmailLoginFormProps> = ({ role, onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
await onSubmit({ email, password, role });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to login');
} finally {
setIsLoading(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
margin="normal"
required
/>
<TextField
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
disabled={isLoading}
sx={{ mt: 3 }}
>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</Box>
);
};
+139
View File
@@ -0,0 +1,139 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Alert, Stack } from '@mui/material';
import { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
interface EmailSignupFormProps {
role: 'email_teacher' | 'email_student';
onSubmit: (
credentials: EmailCredentials,
displayName: string
) => Promise<void>;
}
export const EmailSignupForm: React.FC<EmailSignupFormProps> = ({
role,
onSubmit,
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const validateForm = () => {
if (!email || !password || !confirmPassword || !displayName) {
return 'All fields are required';
}
if (password !== confirmPassword) {
return 'Passwords do not match';
}
if (password.length < 6) {
return 'Password must be at least 6 characters';
}
if (!email.includes('@')) {
return 'Please enter a valid email address';
}
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
setError(null);
setIsLoading(true);
try {
logger.debug('email-signup-form', '🔄 Submitting signup form', {
email,
role,
hasDisplayName: !!displayName,
});
await onSubmit({ email, password, role }, displayName);
} catch (err) {
setError(
err instanceof Error ? err.message : 'An error occurred during signup'
);
logger.error(
'email-signup-form',
'❌ Signup form submission failed',
err
);
} finally {
setIsLoading(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<Stack spacing={2}>
<TextField
required
fullWidth
id="displayName"
label="Display Name"
name="displayName"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoFocus
/>
<TextField
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<TextField
required
fullWidth
name="confirmPassword"
label="Confirm Password"
type="password"
id="confirmPassword"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{error && <Alert severity="error">{error}</Alert>}
<Button
type="submit"
fullWidth
variant="contained"
disabled={
isLoading || !email || !password || !confirmPassword || !displayName
}
>
{isLoading ? 'Signing up...' : 'Sign Up'}
</Button>
</Stack>
</Box>
);
};
+113
View File
@@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { Container, Box, Typography, Tabs, Tab, Paper, Button } from '@mui/material';
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
import { SchoolUploadSection } from '../components/admin/SchoolUploadSection';
import { TimetableUploadSection } from '../components/admin/TimetableUploadSection';
import { logger } from '../../debugConfig';
const SUPER_ADMIN_EMAIL = import.meta.env.VITE_SUPER_ADMIN_EMAIL;
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div hidden={value !== index} {...other}>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
export default function AdminDashboard() {
const { user } = useAuth();
const navigate = useNavigate();
const [tabValue, setTabValue] = useState(0);
const isSuperAdmin = user?.email === SUPER_ADMIN_EMAIL;
logger.debug('admin-page', '🔍 Super admin check', {
userEmail: user?.email,
superAdminEmail: SUPER_ADMIN_EMAIL,
isMatch: isSuperAdmin
});
const handleReturn = () => {
logger.info('admin-page', '🏠 Returning to single player page');
navigate('/single-player');
};
if (!isSuperAdmin) {
logger.error('admin-page', '🚫 Unauthorized access attempt', {
userEmail: user?.email,
requiredEmail: SUPER_ADMIN_EMAIL
});
return (
<Container>
<Typography variant="h4" color="error">Unauthorized Access</Typography>
<Button
onClick={handleReturn}
variant="contained"
sx={{ mt: 2 }}
>
Return to User Page
</Button>
</Container>
);
}
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
return (
<Container maxWidth="lg" sx={{ mt: 4 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2
}}>
<Typography variant="h4">Admin Dashboard</Typography>
<Button
onClick={handleReturn}
variant="outlined"
>
Return to User Page
</Button>
</Box>
<Paper sx={{ width: '100%', mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="System Settings" />
<Tab label="Database Management" />
<Tab label="User Management" />
<Tab label="Schools" />
<Tab label="Timetables" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<Typography variant="h6">System Settings</Typography>
{/* Add system settings components here */}
</TabPanel>
<TabPanel value={tabValue} index={1}>
<Typography variant="h6">Database Management</Typography>
{/* Add database management components here */}
</TabPanel>
<TabPanel value={tabValue} index={2}>
<Typography variant="h6">User Management</Typography>
{/* Add user management components here */}
</TabPanel>
<TabPanel value={tabValue} index={3}>
<SchoolUploadSection />
</TabPanel>
<TabPanel value={tabValue} index={4}>
<TimetableUploadSection />
</TabPanel>
</Paper>
</Container>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Container, Typography, Box, Alert } from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { EmailLoginForm } from './EmailLoginForm';
import { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { user, signIn } = useAuth();
const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user
});
useEffect(() => {
if (user) {
navigate('/single-player');
}
}, [user, navigate]);
const handleLogin = async (credentials: EmailCredentials) => {
try {
setError(null);
await signIn(credentials.email, credentials.password);
navigate('/single-player');
} catch (error) {
logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed');
throw error;
}
};
if (user) {
return null;
}
return (
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 4
}}
>
<Typography variant="h2" component="h1" gutterBottom>
ClassroomCopilot.ai
</Typography>
<Typography variant="h4" gutterBottom>
Login
</Typography>
{error && (
<Alert severity="error" sx={{ width: '100%', maxWidth: 400 }}>
{error}
</Alert>
)}
<Box sx={{ width: '100%', maxWidth: 400 }}>
<EmailLoginForm
role="email_teacher"
onSubmit={handleLogin}
/>
</Box>
</Container>
);
};
export default LoginPage;
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Container,
Typography,
Box,
Button,
Stack,
Divider,
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { EmailSignupForm } from './EmailSignupForm';
import { EmailCredentials } from '../../services/auth/authService';
import { RegistrationService } from '../../services/auth/registrationService';
import { logger } from '../../debugConfig';
import MicrosoftIcon from '@mui/icons-material/Microsoft';
const SignupPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { user } = useAuth();
const registrationService = RegistrationService.getInstance();
// Get role from location state, default to teacher
const { role = 'teacher' } = location.state || {};
const roleDisplay = role === 'teacher' ? 'Teacher' : 'Student';
logger.debug('signup-page', '🔍 Signup page loaded', {
role,
hasUser: !!user,
});
useEffect(() => {
if (user) {
navigate('/single-player');
}
}, [user, navigate]);
const handleSignup = async (
credentials: EmailCredentials,
displayName: string
) => {
try {
const result = await registrationService.register(
credentials,
displayName
);
if (result.user) {
navigate('/single-player');
}
} catch (error) {
logger.error('signup-page', '❌ Registration failed', error);
throw error;
}
};
const switchRole = () => {
navigate('/signup', {
state: { role: role === 'teacher' ? 'student' : 'teacher' },
});
};
if (user) {
return null;
}
return (
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 4,
}}
>
<Typography variant="h2" component="h1" gutterBottom>
ClassroomCopilot.ai
</Typography>
<Typography variant="h4" gutterBottom>
{roleDisplay} Sign Up
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<Button
fullWidth
variant="outlined"
startIcon={<MicrosoftIcon />}
onClick={() => {}}
sx={{ mb: 3 }}
>
Sign up with Microsoft
</Button>
<Divider sx={{ my: 2 }}>OR</Divider>
<EmailSignupForm
role={`email_${role}` as 'email_teacher' | 'email_student'}
onSubmit={handleSignup}
/>
<Stack
direction="row"
spacing={2}
justifyContent="center"
sx={{ mt: 3 }}
>
<Button variant="text" onClick={switchRole}>
Switch to {role === 'teacher' ? 'Student' : 'Teacher'} Sign Up
</Button>
</Stack>
</Box>
</Container>
);
};
export default SignupPage;
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { Button, Box, Typography, Alert } from '@mui/material';
import { logger } from '../../../debugConfig';
import { SchoolNeoDBService } from '../../../services/graph/schoolNeoDBService';
export const SchoolUploadSection = () => {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const handleSchoolUpload = async () => {
try {
setIsCreating(true);
setError(null);
setSuccess(null);
const result = await SchoolNeoDBService.createSchools();
setSuccess(result.message);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to upload school';
logger.error('admin-page', '❌ School upload failed:', error);
setError(errorMessage);
} finally {
setIsCreating(false);
}
};
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Create Schools
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
{success}
</Alert>
)}
<Button
variant="contained"
onClick={handleSchoolUpload}
disabled={isCreating}
>
{isCreating ? 'Creating...' : 'Create Schools'}
</Button>
</Box>
);
};
@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { Button, Box, Typography, Alert } from '@mui/material';
import { useNeoUser } from '../../../contexts/NeoUserContext';
import { TimetableNeoDBService } from '../../../services/graph/timetableNeoDBService';
import { CCTeacherNodeProps } from '../../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
export const TimetableUploadSection = () => {
const { userNode, workerNode } = useNeoUser();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleTimetableUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
setIsUploading(true);
setError(null);
setSuccess(null);
const result = await TimetableNeoDBService.handleTimetableUpload(
event.target.files?.[0],
userNode || undefined,
workerNode?.nodeData as CCTeacherNodeProps | undefined
);
if (result.success) {
setSuccess(result.message);
} else {
setError(result.message);
}
} finally {
setIsUploading(false);
if (event.target) {
event.target.value = '';
}
}
};
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Upload Teacher Timetable
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
{success}
</Alert>
)}
<Button
variant="contained"
component="label"
disabled={isUploading || !workerNode}
>
{isUploading ? 'Uploading...' : 'Upload Timetable'}
<input
type="file"
hidden
accept=".xlsx"
onChange={handleTimetableUpload}
disabled={isUploading}
/>
</Button>
{!workerNode && (
<Typography color="error" sx={{ mt: 1 }}>
No teacher node found. Please ensure you have the correct permissions.
</Typography>
)}
</Box>
);
};
+45
View File
@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import { Button, TextField } from '@mui/material';
import { EmailCredentials } from '../../../services/auth/authService';
import { logger } from '../../../debugConfig';
interface LoginFormProps {
role: 'email_teacher' | 'email_student';
onSubmit: (credentials: EmailCredentials) => Promise<void>;
}
export const LoginForm: React.FC<LoginFormProps> = ({ role, onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
logger.debug('login-form', '🔄 Submitting login form', { role });
await onSubmit({ email, password, role });
};
return (
<form onSubmit={handleSubmit} className="login-form">
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
fullWidth
autoComplete="new-username"
/>
<TextField
label="Password"
type="password"
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
autoComplete="new-password"
/>
<Button type="submit" variant="contained" fullWidth>
Login
</Button>
</form>
);
};
@@ -0,0 +1,23 @@
import { Button } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../../debugConfig';
export const SuperAdminSection = () => {
const navigate = useNavigate();
const handleAdminClick = () => {
logger.info('super-admin-section', '🔑 Navigating to admin page');
navigate('/admin');
};
return (
<Button
onClick={handleAdminClick}
variant="contained"
color="warning"
sx={{ mb: 2 }}
>
Admin Dashboard
</Button>
);
};
@@ -0,0 +1,12 @@
import { CircularProgress, Box } from '@mui/material';
export const LoadingSpinner = () => (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="100vh"
>
<CircularProgress />
</Box>
);
+28
View File
@@ -0,0 +1,28 @@
import React, { useEffect } from 'react';
import { Container, Typography, CircularProgress } from '@mui/material';
import { logger } from '../debugConfig';
const MorphicPage: React.FC = () => {
useEffect(() => {
// Redirect to the nginx-handled /morphic URL
window.location.href = '/morphic';
logger.debug('morphic-page', '🔄 Redirecting to Morphic');
}, []);
return (
<Container sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh'
}}>
<Typography variant="h5" gutterBottom>
Redirecting to Morphic...
</Typography>
<CircularProgress />
</Container>
);
};
export default MorphicPage;
+67
View File
@@ -0,0 +1,67 @@
import { useCallback } from 'react';
import {
ReactFlow,
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
Node,
Edge,
Connection,
addEdge,
BackgroundVariant
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const initialNodes: Node[] = [
{
id: '1',
type: 'input',
data: { label: 'Teacher Node' },
position: { x: 250, y: 25 },
},
{
id: '2',
data: { label: 'Class Node' },
position: { x: 100, y: 125 },
},
{
id: '3',
type: 'output',
data: { label: 'Student Node' },
position: { x: 400, y: 125 },
},
];
const initialEdges: Edge[] = [
{ id: 'e1-2', source: '1', target: '2' },
{ id: 'e1-3', source: '1', target: '3' },
];
export default function TeacherPlanner() {
const [nodes, , onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges],
);
return (
<div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Controls />
<MiniMap />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
</ReactFlow>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { Box } from '@mui/material';
import { HEADER_HEIGHT } from './Layout';
const SearxngPage: React.FC = () => {
return (
<Box sx={{
position: 'absolute',
top: HEADER_HEIGHT,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
bgcolor: 'background.default'
}}>
<iframe
src={`${import.meta.env.VITE_FRONTEND_SITE_URL}/searxng-api/`}
style={{
width: '100%',
height: '100%',
border: 'none',
display: 'block'
}}
title="SearXNG Search"
/>
</Box>
);
};
export default SearxngPage;
@@ -0,0 +1,92 @@
import { TLShapeId } from '@tldraw/tldraw';
export interface AnnotationData {
studentIndex?: number; // undefined for exam/markscheme annotations
pageIndex: number;
shapeId: TLShapeId;
bounds: {
x: number;
y: number;
width: number;
height: number;
};
}
export class AnnotationManager {
private examAnnotations: Set<TLShapeId> = new Set();
private markSchemeAnnotations: Set<TLShapeId> = new Set();
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
this.annotationData.set(shapeId, data);
if (data.studentIndex !== undefined) {
// Student response annotation
let studentSet = this.studentAnnotations.get(data.studentIndex);
if (!studentSet) {
studentSet = new Set();
this.studentAnnotations.set(data.studentIndex, studentSet);
}
studentSet.add(shapeId);
} else {
// Exam or mark scheme annotation
if (data.pageIndex < 0) {
this.examAnnotations.add(shapeId);
} else {
this.markSchemeAnnotations.add(shapeId);
}
}
}
removeAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (!data) return;
if (data.studentIndex !== undefined) {
const studentSet = this.studentAnnotations.get(data.studentIndex);
studentSet?.delete(shapeId);
} else {
if (data.pageIndex < 0) {
this.examAnnotations.delete(shapeId);
} else {
this.markSchemeAnnotations.delete(shapeId);
}
}
this.annotationData.delete(shapeId);
}
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
return Array.from(this.studentAnnotations.get(studentIndex) || []);
}
getAnnotationsForExam(): TLShapeId[] {
return Array.from(this.examAnnotations);
}
getAnnotationsForMarkScheme(): TLShapeId[] {
return Array.from(this.markSchemeAnnotations);
}
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
return this.annotationData.get(shapeId);
}
clear() {
this.examAnnotations.clear();
this.markSchemeAnnotations.clear();
this.studentAnnotations.clear();
this.annotationData.clear();
}
// Future transcription support
addTranscriptionToAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (data) {
this.annotationData.set(shapeId, {
...data
});
}
}
}
@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { Box } from '@mui/material';
import 'tldraw/tldraw.css';
import { CCPdfEditor } from './CCPdfEditor';
import { CCPdfPicker } from './CCPdfPicker';
import { ExamPdfState } from './types';
import './cc-exam-marker.css';
import { HEADER_HEIGHT } from '../../Layout';
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
export const CCExamMarker = () => {
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
const [isExpanded, setIsExpanded] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
setView(newView);
};
const handleNextStudent = () => {
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
if (currentStudentIndex < totalStudents - 1) {
setCurrentStudentIndex(prev => prev + 1);
}
}
};
const handlePreviousStudent = () => {
if (currentStudentIndex > 0) {
setCurrentStudentIndex(prev => prev - 1);
}
};
return (
<Box sx={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
color: 'text.primary',
}}>
{state.phase === 'pick' ? (
<CCPdfPicker
onOpenPdfs={(pdfs) =>
setState({
phase: 'edit',
examPaper: pdfs.examPaper,
markScheme: pdfs.markScheme,
studentResponses: pdfs.studentResponses,
})
}
/>
) : (
<Box sx={{ flex: 1, position: 'relative' }}>
<Box sx={{
position: 'absolute',
inset: 0,
bgcolor: 'background.paper',
}}>
<CCPdfEditor
examPaper={state.examPaper}
markScheme={state.markScheme}
studentResponses={state.studentResponses}
currentView={view}
currentStudentIndex={currentStudentIndex}
onEditorMount={(editor) => {
if (!editor) return null;
const examMarkerProps = {
editor,
currentView: view,
onViewChange: handleViewChange,
currentStudentIndex,
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
onPreviousStudent: handlePreviousStudent,
onNextStudent: handleNextStudent,
getCurrentPdf: () => {
if (!editor) return null;
const currentPageId = editor.getCurrentPageId();
if (currentPageId.includes('exam-page')) {
return state.examPaper;
} else if (currentPageId.includes('mark-scheme-page')) {
return state.markScheme;
} else if (currentPageId.includes('student-response')) {
return state.studentResponses;
}
return null;
},
};
return <CCPanel
examMarkerProps={examMarkerProps}
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={setIsExpanded}
onPinnedChange={setIsPinned}
/>;
}}
/>
</Box>
</Box>
)}
</Box>
);
};
@@ -0,0 +1,113 @@
import { PDFDocument } from 'pdf-lib';
import { useState } from 'react';
import { Editor, exportToBlob } from '@tldraw/tldraw';
import { Button } from '@mui/material';
import { Pdf } from './types';
interface CCExportPdfButtonProps {
editor: Editor;
pdf: Pdf;
}
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
const [exportProgress, setExportProgress] = useState<number | null>(null);
const exportPdf = async (
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void
) => {
const totalThings = pages.length * 2 + 2;
let progressCount = 0;
const tickProgress = () => {
progressCount++;
onProgress(progressCount / totalThings);
};
const pdf = await PDFDocument.load(source);
tickProgress();
const pdfPages = pdf.getPages();
if (pdfPages.length !== pages.length) {
throw new Error('PDF page count mismatch');
}
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
(id) => !pageShapeIds.has(id)
);
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const pdfPage = pdfPages[i];
const {bounds} = page;
const shapesInBounds = allIds.filter((id) => {
const shapePageBounds = editor.getShapePageBounds(id);
if (!shapePageBounds) return false;
return shapePageBounds.collides(bounds);
});
if (shapesInBounds.length === 0) {
tickProgress();
tickProgress();
continue;
}
const exportedPng = await exportToBlob({
editor,
ids: allIds,
format: 'png',
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
});
tickProgress();
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
});
tickProgress();
}
const url = URL.createObjectURL(
new Blob([await pdf.save()], { type: 'application/pdf' })
);
tickProgress();
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
};
return (
<Button
className="CCExportPdfButton"
variant="contained"
color="primary"
onClick={async () => {
setExportProgress(0);
try {
await exportPdf(editor, pdf, setExportProgress);
} finally {
setExportProgress(null);
}
}}
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 1000,
}}
>
{exportProgress
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export PDF'}
</Button>
);
}
@@ -0,0 +1,385 @@
import React from 'react';
import { Box } from '@mui/material';
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
import { Tldraw } from '@tldraw/tldraw';
import { useCallback, useEffect, useState, useRef } from 'react';
import { ExamPdfs } from './types';
import { AnnotationManager, AnnotationData } from './AnnotationManager';
import { logger } from '../../../debugConfig';
const PAGE_SPACING = 32; // Same spacing as the example
interface CCPdfEditorProps extends ExamPdfs {
currentView: 'exam-and-markscheme' | 'student-responses';
currentStudentIndex: number;
onEditorMount: (editor: Editor) => React.ReactNode;
}
export function CCPdfEditor({
examPaper,
markScheme,
studentResponses,
currentView,
currentStudentIndex,
onEditorMount,
}: CCPdfEditorProps) {
const [editor, setEditor] = useState<Editor | null>(null);
const [pagesInitialized, setPagesInitialized] = useState(false);
const annotationManager = useRef(new AnnotationManager());
const handleMount = useCallback((editor: Editor) => {
setEditor(editor);
onEditorMount(editor);
// Subscribe to shape changes
editor.on('change', () => {
const shapes = editor.getCurrentPageShapeIds();
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
totalShapes: shapes.size,
currentPage: editor.getCurrentPageId()
});
shapes.forEach(shapeId => {
const shape = editor.getShape(shapeId);
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
const bounds = editor.getShapePageBounds(shapeId);
if (bounds) {
const currentPageId = editor.getCurrentPageId();
let annotationData: AnnotationData;
if (currentPageId.includes('student-response')) {
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
// Find which page this annotation belongs to by checking collision with page bounds
const pageShapes = Array.from(shapes).filter(id => {
const s = editor.getShape(id);
return s?.isLocked; // Locked shapes are our PDF pages
});
let pageIndex = -1; // Default to -1 if no collision found
for (let i = 0; i < pageShapes.length; i++) {
const pageShape = editor.getShape(pageShapes[i]);
if (!pageShape) continue;
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
if (!pageBounds) continue;
// Check if the annotation's center point is within the page bounds
const annotationCenter = {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
};
if (annotationCenter.x >= pageBounds.x &&
annotationCenter.x <= pageBounds.x + pageBounds.width &&
annotationCenter.y >= pageBounds.y &&
annotationCenter.y <= pageBounds.y + pageBounds.height) {
pageIndex = i;
break;
}
}
logger.debug('cc-exam-marker', '📏 Calculated page index', {
shapeId,
shapeBounds: bounds,
pageIndex,
studentIndex
});
annotationData = {
studentIndex,
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
} else {
// For exam/mark scheme, use current page type as index
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
annotationData = {
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
}
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
shapeId,
annotationData,
currentPage: currentPageId
});
annotationManager.current.addAnnotation(shapeId, annotationData);
}
}
});
});
}, [onEditorMount]);
// Initial setup effect - runs only once when editor is mounted
useEffect(() => {
if (!editor || pagesInitialized) return;
const setupExamAndMarkScheme = async () => {
const examPageId = 'page:exam-page' as TLPageId;
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
// Calculate vertical layout for exam pages
let top = 0;
let widest = 0;
const examPages = examPaper.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
examPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create exam paper page
editor.createPage({
id: examPageId,
name: 'Exam Paper',
});
editor.setCurrentPage(examPageId);
// Create assets and shapes for exam pages
examPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Similar process for mark scheme pages
let markSchemeTop = 0;
const markSchemePages = markScheme.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = markSchemeTop;
markSchemeTop += height + PAGE_SPACING;
return {
...page,
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
};
});
// Create mark scheme page
editor.createPage({
id: markSchemePageId,
name: 'Mark Scheme',
});
editor.setCurrentPage(markSchemePageId);
// Create assets and shapes for mark scheme pages
markSchemePages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Go back to exam page
editor.setCurrentPage(examPageId);
};
const setupStudentResponses = async () => {
const pagesPerStudent = examPaper.pages.length;
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
// Calculate vertical layout
let top = 0;
let widest = 0;
const studentPages = studentResponses.pages
.slice(startPage, endPage)
.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
studentPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create page for this student
editor.createPage({
id: studentPageId,
name: `Student ${studentIndex + 1}`,
});
editor.setCurrentPage(studentPageId);
// Create assets and shapes
studentPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
}
};
// Initial setup of all pages
const setup = async () => {
await setupExamAndMarkScheme();
await setupStudentResponses();
setPagesInitialized(true);
};
setup();
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
// Effect to handle view changes and navigation
useEffect(() => {
if (!editor || !pagesInitialized) return;
// Switch to appropriate page based on current view
const targetPageId = currentView === 'exam-and-markscheme'
? ('page:exam-page' as TLPageId)
: (`page:student-response-${currentStudentIndex}` as TLPageId);
logger.debug('cc-exam-marker', '🔄 Switching view', {
currentView,
currentStudentIndex,
targetPageId
});
editor.setCurrentPage(targetPageId);
// Update camera constraints for current page
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
(acc: TLBox | null, shapeId) => {
const bounds = editor.getShapePageBounds(shapeId);
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
},
null as TLBox | null
);
if (currentPageBounds) {
const isMobile = editor.getViewportScreenBounds().width < 840;
editor.setCameraOptions({
constraints: {
bounds: currentPageBounds,
padding: { x: isMobile ? 16 : 164, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
});
editor.setCamera(editor.getCamera(), { reset: true });
}
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
// Expose annotationManager to parent through onEditorMount
useEffect(() => {
if (editor) {
onEditorMount(editor);
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
editor.annotationManager = annotationManager.current;
}
}, [editor, onEditorMount]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<Tldraw
onMount={handleMount}
components={{
InFrontOfTheCanvas: () => onEditorMount(editor!)
}}
/>
</Box>
);
}
@@ -0,0 +1,210 @@
import { useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
import { ExamPdfs, Pdf, PdfPage } from './types';
interface CCPdfPickerProps {
onOpenPdfs: (pdfs: ExamPdfs) => void;
}
const pageSpacing = 32;
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
const [isLoading, setIsLoading] = useState(false);
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
const PdfJS = await import('pdfjs-dist');
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
const pdf = await PdfJS.getDocument(source.slice()).promise;
const pages: PdfPage[] = [];
const canvas = window.document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Failed to create canvas context');
const visualScale = 1.5;
const scale = window.devicePixelRatio;
let top = 0;
let widest = 0;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: scale * visualScale });
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderContext = {
canvasContext: context,
viewport,
};
await page.render(renderContext).promise;
const width = viewport.width / scale;
const height = viewport.height / scale;
pages.push({
src: canvas.toDataURL(),
bounds: new TLBox(0, top, width, height),
assetId: AssetRecordType.createId(),
shapeId: createShapeId(),
});
top += height + pageSpacing;
widest = Math.max(widest, width);
}
canvas.width = 0;
canvas.height = 0;
for (const page of pages) {
page.bounds.x = (widest - page.bounds.width) / 2;
}
return {
name,
pages,
source,
};
}
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
setIsLoading(true);
try {
const pdf = await loadPdf(file.name, await file.arrayBuffer());
// Validate student responses page count
if (type === 'studentResponses' && selectedPdfs.examPaper) {
const examPageCount = selectedPdfs.examPaper.pages.length;
if (pdf.pages.length % examPageCount !== 0) {
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
return;
}
}
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
} catch (error) {
console.error('Error loading PDF:', error);
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
} finally {
setIsLoading(false);
}
};
const createFileInput = (type: keyof ExamPdfs) => {
const input = window.document.createElement('input');
input.type = 'file';
input.accept = 'application/pdf';
input.addEventListener('change', async (e) => {
const fileList = (e.target as HTMLInputElement).files;
if (!fileList || fileList.length === 0) return;
await handleFileSelect(type, fileList[0]);
});
input.click();
};
const allPdfsSelected = () => {
return (
selectedPdfs.examPaper &&
selectedPdfs.markScheme &&
selectedPdfs.studentResponses
);
};
if (isLoading) {
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Typography>Loading...</Typography>
</Box>
);
}
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Stack
spacing={4}
alignItems="center"
sx={{
maxWidth: '800px',
width: '100%',
p: 3
}}
>
<Typography variant="h5">Select PDF Files</Typography>
<Stack
direction="row"
sx={{
width: '100%',
justifyContent: 'center',
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
}}
>
<Button
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
onClick={() => createFileInput('examPaper')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
</Button>
<Button
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
onClick={() => createFileInput('markScheme')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
</Button>
<Button
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
onClick={() => createFileInput('studentResponses')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
</Button>
</Stack>
{allPdfsSelected() && (
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
<Button
variant="contained"
color="primary"
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
sx={{
minWidth: '180px',
height: '48px'
}}
>
Continue
</Button>
</Box>
)}
</Stack>
</Box>
);
}
@@ -0,0 +1,61 @@
.CCExamMarker {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.CCExamMarker .CCPdfPicker {
position: absolute;
inset: 1rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
gap: 1rem;
}
.CCExamMarker .CCPdfBgRenderer {
position: absolute;
pointer-events: none;
}
.CCExamMarker .CCPdfBgRenderer img {
position: absolute;
}
.CCExamMarker .PageOverlayScreen-screen {
pointer-events: none;
z-index: -1;
fill: var(--color-background);
fill-opacity: 0.8;
stroke: none;
}
.CCExamMarker .PageOverlayScreen-outline {
position: absolute;
pointer-events: none;
z-index: -1;
box-shadow: var(--shadow-2);
}
.CCExamMarker .CCExportPdfButton {
font: inherit;
background: var(--color-primary);
border: none;
color: var(--color-selected-contrast);
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 6px;
margin: 6px;
margin-bottom: 0;
pointer-events: all;
z-index: var(--layer-panels);
border: 2px solid var(--color-background);
cursor: pointer;
}
.CCExamMarker .CCExportPdfButton:hover {
filter: brightness(1.1);
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
export interface PdfPage {
src: string;
bounds: Box;
assetId: TLAssetId;
shapeId: TLShapeId;
}
export interface Pdf {
name: string;
pages: PdfPage[];
source: string | ArrayBuffer;
}
export interface ExamPdfs {
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
}
export type ExamPdfState =
| {
phase: 'pick';
}
| {
phase: 'edit';
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
};
export interface StudentResponse {
studentId: string;
pageStart: number;
pageEnd: number;
}
export interface ExamMetadata {
totalPages: number;
pagesPerStudent: number;
totalStudents: number;
studentResponses: StudentResponse[];
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../debugConfig';
interface LaunchParams {
files: FileSystemFileHandle[];
}
interface LaunchQueue {
setConsumer(callback: (params: LaunchParams) => Promise<void>): void;
}
interface WindowWithLaunchQueue extends Window {
launchQueue: LaunchQueue;
}
const ShareHandler = () => {
const navigate = useNavigate();
useEffect(() => {
const processSharedData = async () => {
try {
// Handle files shared through Web Share Target API
if ('launchQueue' in window) {
(window as WindowWithLaunchQueue).launchQueue.setConsumer(async (launchParams: LaunchParams) => {
if (!launchParams.files.length) {
logger.debug('share-handler', 'No files shared');
return;
}
for (const fileHandle of launchParams.files) {
const file = await fileHandle.getFile();
logger.info('share-handler', 'Processing shared file', {
name: file.name,
type: file.type,
size: file.size
});
// Navigate to single player with the shared file
// You might want to modify this based on your needs
navigate('/single-player', {
state: {
sharedFile: file
}
});
}
});
}
// Handle URL parameters for text/url sharing
const urlParams = new URLSearchParams(window.location.search);
const title = urlParams.get('title');
const text = urlParams.get('text');
const url = urlParams.get('url');
if (title || text || url) {
logger.info('share-handler', 'Processing shared content', {
title,
text,
url
});
// Navigate to single player with the shared content
navigate('/single-player', {
state: {
sharedContent: { title, text, url }
}
});
}
} catch (error) {
logger.error('share-handler', 'Error processing shared content', { error });
}
};
processSharedData();
}, [navigate]);
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}>
Processing shared content...
</div>
);
};
export default ShareHandler;
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { Tldraw } from '@tldraw/tldraw';
import '@tldraw/tldraw/tldraw.css';
const TLDrawCanvas: React.FC = () => {
return (
<div style={{ width: '100%', height: '100%' }}>
<Tldraw persistenceKey="classroom-copilot-landing-page" />
</div>
);
};
export default TLDrawCanvas;
+469
View File
@@ -0,0 +1,469 @@
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
} from '@tldraw/tldraw';
import { useAuth } from '../../contexts/AuthContext';
import { useTLDraw } from '../../contexts/TLDrawContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
// Tldraw utils
import { customAssets } from '../../utils/tldraw/assets';
import { devEmbeds } from '../../utils/tldraw/embeds';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { devTools } from '../../utils/tldraw/tools';
import { customSchema } from '../../utils/tldraw/schemas';
// Layout
import { HEADER_HEIGHT } from '../Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
interface EventFilter {
type: 'all' | 'ui' | 'store' | 'canvas';
subType?: string;
enabled: boolean;
}
interface EventFilters {
mode: 'all' | 'specific';
filters: {
[key: string]: EventFilter;
};
}
const EventMonitoringControls: React.FC<{
filters: EventFilters;
setFilters: (filters: EventFilters) => void;
onClear: () => void;
}> = ({ filters, setFilters, onClear }) => {
const handleModeChange = (mode: 'all' | 'specific') => {
setFilters({ ...filters, mode });
};
const handleFilterChange = (key: string, enabled: boolean) => {
setFilters({
...filters,
filters: {
...filters.filters,
[key]: { ...filters.filters[key], enabled }
}
});
};
return (
<div className="event-monitor-controls">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="mode-selector">
<label>
<input
type="radio"
checked={filters.mode === 'all'}
onChange={() => handleModeChange('all')}
/>
Monitor All Events
</label>
<label>
<input
type="radio"
checked={filters.mode === 'specific'}
onChange={() => handleModeChange('specific')}
/>
Monitor Specific Events
</label>
</div>
<button
onClick={onClear}
style={{
padding: '4px 8px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Clear Logs
</button>
</div>
{filters.mode === 'specific' && (
<div className="specific-filters">
<select
onChange={(e) => handleFilterChange(e.target.value, true)}
value=""
>
<option value="" disabled>Add Event Filter</option>
<optgroup label="UI Events">
<option value="ui-selection">Selection Changes</option>
<option value="ui-tool">Tool Changes</option>
<option value="ui-viewport">Viewport Changes</option>
</optgroup>
<optgroup label="Store Events">
<option value="store-shapes">Shape Updates</option>
<option value="store-bindings">Binding Updates</option>
<option value="store-assets">Asset Updates</option>
</optgroup>
<optgroup label="Canvas Events">
<option value="canvas-pointer">Pointer Events</option>
<option value="canvas-camera">Camera Events</option>
<option value="canvas-selection">Selection Events</option>
</optgroup>
</select>
<div className="active-filters">
{Object.entries(filters.filters)
.filter(([, filter]) => filter.enabled)
.map(([key]) => (
<div key={key} className="filter-tag">
{key}
<button onClick={() => handleFilterChange(key, false)}>×</button>
</div>
))}
</div>
</div>
)}
</div>
);
};
const MAX_EVENTS = 100; // Limit visible events to last 100
const EventDisplay: React.FC<{ events: Array<{ type: string; data: string; timestamp: string }> }> =
({ events }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
}, [events]);
// Only show the last MAX_EVENTS events
const visibleEvents = useMemo(() =>
events.slice(-MAX_EVENTS),
[events]
);
return (
<div
ref={scrollContainerRef}
className="event-display"
style={{
flex: 1,
padding: 8,
background: '#ddd',
borderLeft: 'solid 2px #333',
fontFamily: 'monospace',
fontSize: 12,
overflow: 'auto',
scrollBehavior: 'smooth',
}}
>
{visibleEvents.length === MAX_EVENTS && (
<div style={{
padding: '4px 8px',
marginBottom: 8,
backgroundColor: '#fff3cd',
color: '#856404',
borderRadius: 4,
fontSize: 11,
}}>
Showing last {MAX_EVENTS} events only
</div>
)}
{visibleEvents.map((event, i) => (
<pre
key={event.timestamp + i}
style={{
borderBottom: '1px solid #000',
marginBottom: 0,
paddingBottom: '12px',
backgroundColor: getEventTypeColor(event.type),
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
<span className="event-timestamp">{event.timestamp}</span>
<span className="event-type">[{event.type}]</span>
{event.data}
</pre>
))}
</div>
);
};
const getEventTypeColor = (type: string): string => {
switch (type) {
case 'ui':
return '#e8f0fe'; // Light blue
case 'store':
return '#fef3e8'; // Light orange
case 'canvas':
return '#f0fee8'; // Light green
default:
return 'transparent';
}
};
export default function DevPage() {
const { user } = useAuth();
const navigate = useNavigate();
const { tldrawPreferences, initializePreferences, setTldrawPreferences } = useTLDraw();
const [events, setEvents] = useState<Array<{ type: 'ui' | 'store' | 'canvas'; data: string; timestamp: string; }>>([]);
const [eventFilters, setEventFilters] = useState<EventFilters>({ mode: 'all', filters: {} });
const [logPanelWidth, setLogPanelWidth] = useState(30); // Width in percentage
const editorRef = useRef<Editor | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
document.body.style.cursor = 'col-resize';
const handleDragMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const windowWidth = window.innerWidth;
const newWidth = (e.clientX / windowWidth) * 100;
// Limit the range to between 20% and 80%
const clampedWidth = Math.min(Math.max(newWidth, 20), 80);
setLogPanelWidth(100 - clampedWidth);
};
const handleDragUp = () => {
isDraggingRef.current = false;
document.body.style.cursor = 'default';
window.removeEventListener('mousemove', handleDragMove);
window.removeEventListener('mouseup', handleDragUp);
};
window.addEventListener('mousemove', handleDragMove);
window.addEventListener('mouseup', handleDragUp);
}, []);
// Create tldraw user
const tldrawUser = useTldrawUser({
userPreferences: {
id: user?.id ?? 'dev-user',
name: user?.display_name ?? 'Unknown User',
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
// Create store
const store = useMemo(() => localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
}), []);
// Initialize preferences when user is available
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.debug('dev-page', '🔄 Initializing preferences for user', { userId: user.id });
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Redirect if no user
useEffect(() => {
if (!user) {
logger.info('dev-page', '🚪 Redirecting to home - no user logged in');
navigate('/');
}
}, [user, navigate]);
const shouldCaptureEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
if (eventFilters.mode === 'all') return true;
// Check specific filters
return Object.entries(eventFilters.filters)
.some(([key, filter]) => {
if (!filter.enabled) return false;
const [filterType, filterSubType] = key.split('-');
if (filterType !== type) return false;
// Match specific event subtypes
switch (filterType) {
case 'ui':
return data.includes(filterSubType);
case 'store':
return data.includes(`"type":"${filterSubType}"`);
case 'canvas':
return data.includes(`Canvas Event: ${filterSubType}`);
default:
return false;
}
});
}, [eventFilters]);
const addEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
if (!shouldCaptureEvent(type, data)) return;
setEvents(prevEvents => {
const newEvents = [...prevEvents, {
type,
data,
timestamp: new Date().toISOString()
}];
// Keep last 2 * MAX_EVENTS in state to allow some scrollback
return newEvents.slice(-(MAX_EVENTS * 2));
});
}, [shouldCaptureEvent]);
const handleUiEvent = useCallback((name: string, data: unknown) => {
const eventString = `UI Event: ${name} ${JSON.stringify(data)}`;
addEvent('ui', eventString);
console.log(eventString);
}, [addEvent]);
const handleCanvasEvent = useCallback((editor: Editor) => {
logger.trace('dev-page', '🎨 Canvas editor mounted');
editor.on('change', () => {
const camera = editor.getCamera();
logger.trace('dev-page', '🎥 Camera changed', { camera });
addEvent('canvas', `Canvas Event: camera ${JSON.stringify(camera)}`);
});
editor.on('change', () => {
const selectedIds = editor.getSelectedShapeIds();
if (selectedIds.length > 0) {
logger.trace('dev-page', '🔍 Selection changed', { selectedIds });
addEvent('canvas', `Canvas Event: selection ${JSON.stringify(selectedIds)}`);
}
});
editor.on('event', (info) => {
if (info.type === 'pointer') {
const point = editor.inputs.currentPagePoint;
logger.trace('dev-page', '👆 Pointer event', { point });
addEvent('canvas', `Canvas Event: pointer ${JSON.stringify(point)}`);
}
});
}, [addEvent]);
useEffect(() => {
if (store) {
const cleanupFn = store.listen((info) => {
const eventString = `Store Event: ${info.source} ${JSON.stringify(info.changes)}`;
addEvent('store', eventString);
console.log(eventString);
});
return () => cleanupFn();
}
}, [store, addEvent]);
useEffect(() => {
if (scrollContainerRef.current) {
const scrollContainer = scrollContainerRef.current;
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}, [events]);
const clearEvents = useCallback(() => {
setEvents([]);
}, []);
if (!user) {
logger.info('dev-page', '🚫 Rendering null - no user');
return null;
}
return (
<div style={{
display: 'flex',
width: '100%',
height: `calc(100vh - ${HEADER_HEIGHT}px)`,
position: 'fixed',
top: `${HEADER_HEIGHT}px`
}}>
<div style={{
width: `${100 - logPanelWidth}%`,
height: '100%',
position: "absolute",
left: 0,
overflow: 'hidden'
}}>
<Tldraw
user={tldrawUser}
store={store}
onMount={(editor) => {
editorRef.current = editor;
handleCanvasEvent(editor);
logger.info('system', '🎨 Tldraw mounted', {
editorId: editor.store.id
});
}}
onUiEvent={handleUiEvent}
tools={devTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
embeds={devEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
/>
</div>
<div
style={{
width: '5px',
height: '100%',
position: 'absolute',
left: `${100 - logPanelWidth}%`,
transform: 'translateX(-50%)',
cursor: 'col-resize',
backgroundColor: 'transparent',
zIndex: 1000,
}}
onMouseDown={handleDragStart}
>
<div style={{
width: '1px',
height: '100%',
backgroundColor: '#333',
margin: '0 auto',
}} />
</div>
<div
style={{
width: `${logPanelWidth}%`,
height: '100%',
position: 'absolute',
right: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<EventMonitoringControls
filters={eventFilters}
setFilters={setEventFilters}
onClear={clearEvents}
/>
<EventDisplay events={events} />
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useMemo, useRef } from 'react';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLAnyShapeUtilConstructor
} from '@tldraw/tldraw';
// App context
import { useTLDraw } from '../../contexts/TLDrawContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { devEmbeds } from '../../utils/tldraw/embeds';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { devTools } from '../../utils/tldraw/tools';
import { customSchema } from '../../utils/tldraw/schemas';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
const devUserId = 'dev-user';
export default function TLDrawDevPage() {
// 1. All context hooks first
const {
tldrawPreferences,
initializePreferences,
presentationMode,
setTldrawPreferences
} = useTLDraw();
// 2. All refs
const editorRef = useRef<Editor | null>(null);
// 4. All memos
const tldrawUser = useTldrawUser({
userPreferences: {
id: devUserId,
name: 'Dev User',
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
const store = useMemo(() => localStoreService.getStore({
schema: customSchema,
shapeUtils: [...allShapeUtils] as TLAnyShapeUtilConstructor[],
bindingUtils: allBindingUtils
}), []);
// Initialize preferences when user is available
useEffect(() => {
if (!tldrawPreferences) {
logger.debug('single-player-page', '🔄 Initializing preferences');
initializePreferences(devUserId);
}
}, [tldrawPreferences, initializePreferences]);
// Load initial data when user node is available
useEffect(() => {
if (!tldrawUser) {
return;
}
}, [tldrawUser, store]);
// Handle presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
logger.info('presentation', '🔄 Presentation mode changed', {
presentationMode,
editorExists: !!editorRef.current
});
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
logger.info('presentation', '🧹 Cleaning up presentation mode');
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Modify the render logic to use presentationMode
const uiOverrides = getUiOverrides(presentationMode);
const uiComponents = getUiComponents(presentationMode);
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Tldraw
user={tldrawUser}
store={store}
tools={devTools}
shapeUtils={allShapeUtils as TLAnyShapeUtilConstructor[]}
bindingUtils={allBindingUtils}
components={uiComponents}
overrides={uiOverrides}
embeds={devEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
onMount={(editor) => {
logger.info('system', '🎨 Tldraw mounted', {
editorId: editor.store.id,
presentationMode
});
editorRef.current = editor;
}}
/>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES,
} from '@tldraw/tldraw';
import { useSync } from '@tldraw/sync';
// App context
import { useAuth } from '../../contexts/AuthContext';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
// Tldraw services
import { multiplayerOptions } from '../../services/tldraw/optionsService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { createSyncConnectionOptions, handleExternalAsset } from '../../services/tldraw/syncService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { multiplayerTools } from '../../utils/tldraw/tools';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { customSchema } from '../../utils/tldraw/schemas';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http')
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
export default function TldrawMultiUser() {
const { user } = useAuth();
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const {
tldrawPreferences,
setTldrawPreferences,
initializePreferences,
presentationMode
} = useTLDraw();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const editorRef = useRef<Editor | null>(null);
// Get room ID from URL params
const roomId = searchParams.get('room') || 'multiplayer';
// Memoize user information to ensure consistency
const userInfo = useMemo(() => ({
id: user?.id ?? '',
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]);
// Create editor user with memoization
const editorUser = useTldrawUser({
userPreferences: {
id: userInfo.id,
name: userInfo.name,
color: userInfo.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
const connectionOptions = useMemo(() => createSyncConnectionOptions({
userId: userInfo.id,
displayName: userInfo.name,
color: userInfo.color,
roomId,
baseUrl: SYNC_WORKER_URL
}), [userInfo, roomId]);
const store = useSync({
...connectionOptions,
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils,
userInfo: {
id: userInfo.id,
name: userInfo.name,
color: userInfo.color
}
});
// Log connection status changes
useEffect(() => {
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
status: store.status,
connectionOptions
});
}, [store.status, connectionOptions]);
// Effect for initializing preferences
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.info('multiplayer-page', '🔄 Initializing preferences');
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Effect for redirecting if user is not authenticated
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
// Effect for presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Memoize UI overrides and components
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
// Render conditionally to avoid unnecessary rerenders
if (!user) {
return null;
}
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)'
}}>
<div style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}>
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
</div>
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Tldraw
user={editorUser}
store={store.store}
onMount={(editor) => {
editorRef.current = editor;
editor.registerExternalAssetHandler('url', async ({ url }: { url: string }) => {
return handleExternalAsset(SYNC_WORKER_URL, url);
});
}}
options={multiplayerOptions}
embeds={multiplayerEmbeds}
tools={multiplayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
overrides={uiOverrides}
components={uiComponents}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
/>
</div>
);
}
+514
View File
@@ -0,0 +1,514 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore,
TLStoreWithStatus
} from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useUser } from '../../contexts/UserContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { singlePlayerTools } from '../../utils/tldraw/tools';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { singlePlayerEmbeds } from '../../utils/tldraw/embeds';
import { customSchema } from '../../utils/tldraw/schemas';
// Navigation
import { useNavigationStore } from '../../stores/navigationStore';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
import { CircularProgress, Alert, Snackbar } from '@mui/material';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { NodeData } from '../../types/graph-shape';
import { NavigationNode } from '../../types/navigation';
interface LoadingState {
status: 'ready' | 'loading' | 'error';
error: string;
}
export default function SinglePlayerPage() {
// Context hooks with initialization states
const { user, loading: userLoading } = useUser();
const {
tldrawPreferences,
initializePreferences,
presentationMode,
setTldrawPreferences
} = useTLDraw();
const routerNavigate = useNavigate();
const location = useLocation();
// Navigation store
const { context } = useNavigationStore();
// Refs
const editorRef = useRef<Editor | null>(null);
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
// State
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isEditorReady, setIsEditorReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
// TLDraw user preferences
const tldrawUser = useTldrawUser({
userPreferences: {
id: user?.id ?? '',
name: user?.display_name,
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
// Initialize store
useEffect(() => {
if (!isEditorReady) {
logger.debug('single-player-page', '⏳ Waiting for editor to be ready');
return;
}
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
return;
}
if (!editorRef.current) {
logger.debug('single-player-page', '⏳ Waiting for editor ref');
return;
}
logger.info('single-player-page', '🔄 Starting store initialization', {
isEditorReady,
hasUser: !!user,
userType: user.user_type,
username: user.username
});
const initializeStoreAndSnapshot = async () => {
try {
setLoadingState({ status: 'loading', error: '' });
// 1. Create store
logger.debug('single-player-page', '🔄 Creating TLStore');
const newStore = localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
});
logger.debug('single-player-page', '✅ TLStore created');
// 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
tldraw_snapshot: context.node.tldraw_snapshot,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.tldraw_snapshot,
user.user_db_name,
newStore,
setLoadingState
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 4. Set up auto-save
newStore.listen(() => {
if (snapshotServiceRef.current && context.node) {
logger.debug('single-player-page', '💾 Auto-saving changes');
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Auto-save failed', error);
});
}
});
// 5. Update store state
setStore(newStore);
setLoadingState({ status: 'ready', error: '' });
logger.info('single-player-page', '✅ Store initialization complete');
// 6. Handle cleanup
return () => {
logger.debug('single-player-page', '🧹 Starting cleanup');
if (snapshotServiceRef.current) {
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Final save failed', error);
});
snapshotServiceRef.current.clearCurrentNode();
snapshotServiceRef.current = null;
}
newStore.dispose();
setStore(undefined);
logger.debug('single-player-page', '🧹 Cleanup complete');
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
logger.error('single-player-page', '❌ Store initialization failed', error);
setLoadingState({ status: 'error', error: errorMessage });
return undefined;
}
};
initializeStoreAndSnapshot();
}, [isEditorReady, user, context.node, editorRef.current]);
// Handle initial node placement
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
return;
}
try {
setLoadingState({ status: 'loading', error: '' });
// Center the node
const nodeData = await loadNodeData(context.node);
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
setIsInitialLoad(false);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to place initial node', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to place initial node'
});
}
};
placeInitialNode();
}, [context.node, store, isInitialLoad]);
// Handle navigation changes
useEffect(() => {
const handleNodeChange = async () => {
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
return;
}
// We can safely assert these types because we've checked for null above
const editor = editorRef.current as Editor;
const snapshotService = snapshotServiceRef.current;
const currentNode = context.node;
try {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: currentNode.id,
tldraw_snapshot: currentNode.tldraw_snapshot,
isInitialLoad
});
// Get the previous node from navigation history
const previousNode = context.history.currentIndex > 0
? context.history.nodes[context.history.currentIndex - 1]
: null;
// Handle navigation in snapshot service
await snapshotService.handleNavigationStart(previousNode, currentNode);
// Center the node on canvas
const nodeData = await loadNodeData(currentNode);
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to load node data', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load node data'
});
}
};
handleNodeChange();
}, [context.node?.id, context.history, store]);
// Initialize preferences when user is available
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.debug('single-player-page', '🔄 Initializing preferences for user', { userId: user.id });
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Redirect if no user or incorrect role
useEffect(() => {
if (!user || user.user_type !== 'admin') {
logger.info('single-player-page', '🚪 Redirecting to home - no user or incorrect role', {
hasUser: !!user,
userType: user?.user_type
});
routerNavigate('/', { replace: true });
}
}, [user, routerNavigate]);
// Handle presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
logger.info('presentation', '🔄 Presentation mode changed', {
presentationMode,
editorExists: !!editorRef.current
});
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
logger.info('presentation', '🧹 Cleaning up presentation mode');
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Handle shared content
useEffect(() => {
const handleSharedContent = async () => {
if (!editorRef.current || !location.state) {
return;
}
const editor = editorRef.current;
const { sharedFile, sharedContent } = location.state as {
sharedFile?: File;
sharedContent?: {
title?: string;
text?: string;
url?: string;
};
};
if (sharedFile) {
logger.info('single-player-page', '📤 Processing shared file', {
name: sharedFile.name,
type: sharedFile.type
});
try {
// Handle different file types
if (sharedFile.type.startsWith('image/')) {
const imageUrl = URL.createObjectURL(sharedFile);
await editor.createShape({
type: 'image',
props: {
url: imageUrl,
w: 320,
h: 240,
name: sharedFile.name
}
});
URL.revokeObjectURL(imageUrl);
} else if (sharedFile.type === 'application/pdf') {
// Handle PDF (you might want to implement PDF handling)
logger.info('single-player-page', '📄 PDF handling not implemented yet');
} else if (sharedFile.type === 'text/plain') {
const text = await sharedFile.text();
editor.createShape({
type: 'text',
props: { text }
});
}
} catch (error) {
logger.error('single-player-page', '❌ Error processing shared file', { error });
}
}
if (sharedContent) {
logger.info('single-player-page', '📤 Processing shared content', { sharedContent });
const { title, text, url } = sharedContent;
let contentText = '';
if (title) {
contentText += `${title}\n`;
}
if (text) {
contentText += `${text}\n`;
}
if (url) {
contentText += url;
}
if (contentText) {
editor.createShape({
type: 'text',
props: { text: contentText }
});
}
}
};
handleSharedContent();
}, [location.state]);
// Modify the render logic to use presentationMode
const uiOverrides = getUiOverrides(presentationMode);
const uiComponents = getUiComponents(presentationMode);
// Show loading state if user context is still loading
if (userLoading) {
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--color-background)'
}}>
<CircularProgress />
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
zIndex: 1000,
}}>
<CircularProgress />
</div>
)}
{/* Error snackbar */}
<Snackbar
open={loadingState.status === 'error'}
autoHideDuration={6000}
onClose={() => setLoadingState({ status: 'ready', error: '' })}
>
<Alert severity="error" onClose={() => setLoadingState({ status: 'ready', error: '' })}>
{loadingState.error}
</Alert>
</Snackbar>
<Tldraw
user={tldrawUser}
store={store}
tools={singlePlayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
components={uiComponents}
overrides={uiOverrides}
embeds={singlePlayerEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
onMount={(editor) => {
logger.info('single-player-page', '🎨 Starting Tldraw mount');
try {
if (!editor) {
logger.error('single-player-page', '❌ Editor is null in onMount');
return;
}
editorRef.current = editor;
logger.debug('single-player-page', '✅ Editor ref set');
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id,
presentationMode,
isEditorReady: true
});
} catch (error) {
logger.error('single-player-page', '❌ Error in onMount', error);
}
}}
/>
</div>
);
}
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// 1. Always fetch fresh data
const dbName = UserNeoDBService.getNodeDatabaseName(node);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: fetchedData.node_data.title || node.label,
w: 500,
h: 350,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
};
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Typography, Button, Container, useTheme } from "@mui/material";
import { useAuth } from "../../contexts/AuthContext";
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { logger } from '../../debugConfig';
function NotFound() {
const theme = useTheme();
const navigate = useNavigate();
const { user } = useAuth();
useEffect(() => {
logger.debug('not-found', '🔄 Not Found page rendered', {
hasUser: !!user,
userId: user?.id
});
}, [user]);
const handleReturn = () => {
const returnPath = user ? '/single-player' : '/';
logger.debug('not-found', '🔄 Navigating to return path', { returnPath });
navigate(returnPath);
};
return (
<Container maxWidth="sm">
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
textAlign: 'center',
gap: 3
}}
>
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
<Typography variant="h2" component="h1" gutterBottom>
404
</Typography>
<Typography variant="h5" gutterBottom>
Page Not Found
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
The page you're looking for doesn't exist or has been moved.
</Typography>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleReturn}
>
Return to {user ? 'Canvas' : 'Home'}
</Button>
</Box>
</Container>
);
}
export default NotFound;
+530
View File
@@ -0,0 +1,530 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { EventContentArg, EventClickArg, CalendarOptions } from '@fullcalendar/core';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import multiMonthPlugin from '@fullcalendar/multimonth'; // Import the multiMonth plugin for year view
import listPlugin from '@fullcalendar/list';
import { useAuth } from '../../contexts/AuthContext';
import { useNeoUser } from '../../contexts/NeoUserContext';
import { FaEllipsisV } from 'react-icons/fa';
import { logger } from '../../debugConfig';
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
interface Event {
id: string;
title: string;
start: string;
end: string;
groupId?: string;
extendedProps?: {
subjectClass: string;
color: string;
periodCode: string;
tldraw_snapshot?: string;
};
}
function lightenColor(color: string, amount: number): string {
// Remove the '#' if it exists
color = color.replace(/^#/, '');
// Parse the color
let r = parseInt(color.slice(0, 2), 16);
let g = parseInt(color.slice(2, 4), 16);
let b = parseInt(color.slice(4, 6), 16);
// Convert to HSL
const [h, s, l] = rgbToHsl(r, g, b);
// Adjust the lightness based on the current lightness
const newL = l < 0.5 ? l + (1 - l) * amount : l + (1 - l) * amount * 0.5;
// Convert back to RGB
[r, g, b] = hslToRgb(h, s, newL);
// Convert to hex and return
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s, l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h, s, l];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) {
t += 1;
}
if (t > 1) {
t -= 1;
}
if (t < 1/6) {
return p + (q - p) * 6 * t;
}
if (t < 1/2) {
return q;
}
if (t < 2/3) {
return p + (q - p) * (2/3 - t) * 6;
}
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
const CalendarPage: React.FC = () => {
const [events, setEvents] = useState<Event[]>([]);
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
const { user } = useAuth();
const calendarRef = useRef<FullCalendar>(null);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const [hiddenSubjectClassDivs, setHiddenSubjectClassDivs] = useState<string[]>([]);
const [hiddenPeriodCodeDivs, setHiddenPeriodCodeDivs] = useState<string[]>([]);
const [hiddenTimeDivs, setHiddenTimeDivs] = useState<string[]>([]);
const [eventRange, setEventRange] = useState<{ start: Date | null; end: Date | null }>({ start: null, end: null });
const { workerNode, isLoading, error, workerDbName } = useNeoUser();
const getEventRange = useCallback((events: Event[]) => {
if (events.length === 0) {
return { start: null, end: null };
}
let start = new Date(events[0].start);
let end = new Date(events[0].end);
events.forEach(event => {
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
if (eventStart < start) {
start = eventStart;
}
if (eventEnd > end) {
end = eventEnd;
}
});
// Adjust start to the beginning of its month and end to the end of its month
start.setDate(1);
end.setMonth(end.getMonth() + 1, 0);
return { start, end };
}, []);
const fetchEvents = useCallback(async () => {
if (!user || isLoading || error || !workerNode?.nodeData) {
if (error) {
logger.error('calendar', 'NeoUser context error', { error });
}
return;
}
try {
logger.debug('calendar', 'Fetching events', {
unique_id: workerNode.nodeData.unique_id,
school_db_name: workerDbName
});
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
workerNode.nodeData.unique_id,
workerDbName || ''
);
const transformedEvents = events.map(event => ({
...event,
extendedProps: {
...event.extendedProps,
tldraw_snapshot: workerNode?.nodeData?.tldraw_snapshot
}
}));
setEvents(transformedEvents);
const classes: string[] = [];
transformedEvents.forEach((event: Event) => {
if (event.extendedProps?.subjectClass && !classes.includes(event.extendedProps.subjectClass)) {
classes.push(event.extendedProps.subjectClass);
}
});
setSelectedClasses(classes);
const range = getEventRange(transformedEvents);
setEventRange(range);
} catch (error) {
logger.error('calendar', 'Error fetching events', { error });
}
}, [user, workerNode, workerDbName, isLoading, error, getEventRange]);
useEffect(() => {
fetchEvents();
}, [fetchEvents]);
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot;
if (tldraw_snapshot) {
// TODO: Implement tldraw_snapshot retrieval from storage API
// For now, we'll just log it
console.log('TLDraw snapshot:', tldraw_snapshot);
}
}, []);
const filteredEvents = useMemo(() =>
events.filter(event =>
selectedClasses.includes(event.extendedProps?.subjectClass || '')
), [events, selectedClasses]
);
const handleResize = useCallback(() => {
if (calendarRef.current) {
calendarRef.current.getApi().updateSize();
}
}, []);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
const toggleDropdown = useCallback((eventId: string) => {
setOpenDropdownId(openDropdownId === eventId ? null : eventId);
}, [openDropdownId]);
const toggleSubjectClassDivVisibility = useCallback((subjectClass: string) => {
setHiddenSubjectClassDivs(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
}, []);
const togglePeriodCodeDivVisibility = useCallback((subjectClass: string) => {
setHiddenPeriodCodeDivs(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
}, []);
const toggleTimeDivVisibility = useCallback((subjectClass: string) => {
setHiddenTimeDivs(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
}, []);
const hideSubjectClassFromView = useCallback((subjectClass: string) => {
setSelectedClasses(prev => prev.filter(c => c !== subjectClass));
}, []);
const toggleAllDivs = useCallback((subjectClass: string, hide: boolean) => {
const updateHiddenDivs = (prev: string[]) =>
hide ? [...prev, subjectClass] : prev.filter(c => c !== subjectClass);
setHiddenSubjectClassDivs(updateHiddenDivs);
setHiddenPeriodCodeDivs(updateHiddenDivs);
setHiddenTimeDivs(updateHiddenDivs);
}, []);
const areAllDivsHidden = useCallback((subjectClass: string) => {
return hiddenSubjectClassDivs.includes(subjectClass) &&
hiddenPeriodCodeDivs.includes(subjectClass) &&
hiddenTimeDivs.includes(subjectClass);
}, [hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs]);
const renderEventContent = useCallback((eventInfo: EventContentArg) => {
const { event } = eventInfo;
const subjectClass = event.extendedProps?.subjectClass || 'Subject Class';
const originalColor = event.extendedProps?.color || '#ffffff';
const lightenedColor = lightenColor(originalColor, 0.9);
const eventStyle = {
backgroundColor: lightenedColor,
color: '#000',
padding: '4px 6px',
borderRadius: '6px',
fontSize: '1.0em',
overflow: 'visible',
display: 'flex',
flexDirection: 'column' as const,
height: '100%',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
border: `2px solid ${originalColor}`,
position: 'relative' as const,
};
const titleStyle = {
fontWeight: 'bold' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
paddingRight: '20px',
};
const contentStyle = {
fontSize: '0.8em',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis'
};
const ellipsisStyle = {
position: 'absolute' as const,
top: '4px',
right: '4px',
cursor: 'pointer',
zIndex: 10,
};
return (
<div className={`custom-event-content ${openDropdownId === event.id ? 'event-with-dropdown' : ''}`} style={eventStyle}>
<div style={titleStyle}>{event.title}</div>
<div style={ellipsisStyle}>
<FaEllipsisV onClick={(e) => {
e.stopPropagation();
toggleDropdown(event.id);
}} />
</div>
{openDropdownId === event.id && (
<div className="event-dropdown" style={{ position: 'absolute'}}>
<div onClick={(e) => {
e.stopPropagation();
hideSubjectClassFromView(subjectClass);
setOpenDropdownId(null);
}}>
Hide this class from view
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleAllDivs(subjectClass, !areAllDivsHidden(subjectClass));
setOpenDropdownId(null);
}}>
{areAllDivsHidden(subjectClass) ? 'Show' : 'Hide'} all divs
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleSubjectClassDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenSubjectClassDivs.includes(subjectClass) ? 'Show' : 'Hide'} subject class
</div>
<div onClick={(e) => {
e.stopPropagation();
togglePeriodCodeDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenPeriodCodeDivs.includes(subjectClass) ? 'Show' : 'Hide'} period code
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleTimeDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenTimeDivs.includes(subjectClass) ? 'Show' : 'Hide'} time
</div>
</div>
)}
{!hiddenSubjectClassDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-subject-class">{subjectClass}</div>
)}
{!hiddenPeriodCodeDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-period">{event.extendedProps?.periodCode || 'Period Code'}</div>
)}
{!hiddenTimeDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-time">
{event.start?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
{event.end?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
)}
</div>
);
}, [openDropdownId, hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs, toggleDropdown, hideSubjectClassFromView, toggleAllDivs, areAllDivsHidden, toggleSubjectClassDivVisibility, togglePeriodCodeDivVisibility, toggleTimeDivVisibility]);
const calendarOptions: CalendarOptions = useMemo(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, multiMonthPlugin, listPlugin],
initialView: "timeGridWeek",
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'viewToggle filterClassesButton'
},
customButtons: {
filterClassesButton: {
text: 'Filter Classes',
click: () => {} // We'll implement this differently later
},
viewToggle: {
text: 'Change View',
click: () => {} // We'll implement this differently later
}
},
views: {
dayGridYear: {
type: 'dayGrid',
duration: { years: 1 },
buttonText: 'Year Grid',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
dayGridMonth: {
buttonText: 'Month',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
timeGridWeek: {
buttonText: 'Week',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
timeGridDay: {
buttonText: 'Day',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listYear: {
buttonText: 'List Year',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listMonth: {
buttonText: 'List Month',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listWeek: {
buttonText: 'List Week',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listDay: {
buttonText: 'List Day',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
},
validRange: eventRange.start && eventRange.end ? {
start: eventRange.start,
end: eventRange.end
} : undefined,
events: filteredEvents,
height: "100%",
slotMinTime: "08:00:00",
slotMaxTime: "17:00:00",
allDaySlot: false,
expandRows: true,
slotEventOverlap: false,
slotDuration: "00:30:00",
slotLabelInterval: "01:00",
eventContent: renderEventContent,
eventClassNames: (arg: { event: { extendedProps?: { subjectClass?: string } } }) =>
[arg.event.extendedProps?.subjectClass || ''],
eventDidMount: (arg: { event: { extendedProps?: { color?: string }; id: string }; el: HTMLElement }) => {
if (arg.event.extendedProps?.color) {
const originalColor = arg.event.extendedProps.color;
const lightenedColor = lightenColor(originalColor, 0.4);
arg.el.style.backgroundColor = lightenedColor;
arg.el.style.borderColor = originalColor;
}
const updateEventContent = () => {
const height = arg.el.offsetHeight;
const contentElements = arg.el.querySelectorAll('.custom-event-content > div:not(.event-dropdown)');
contentElements.forEach((el, index) => {
const element = el as HTMLElement;
if (index === 0 || index === 1) {
element.style.display = 'block';
} else if (height >= 40 && index === 2) {
element.style.display = 'block';
} else if (height >= 60 && index === 3) {
element.style.display = 'block';
} else if (height >= 80 && index === 4) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
};
updateEventContent();
const resizeObserver = new ResizeObserver(updateEventContent);
resizeObserver.observe(arg.el);
return () => resizeObserver.disconnect();
},
eventClick: handleEventClick,
}), [eventRange.start, eventRange.end, filteredEvents, renderEventContent, handleEventClick]);
if (!user) {
console.log('User not logged in');
return <div>Please log in to view your calendar.</div>;
}
return (
<div className="calendar-page">
<div className="calendar-container" style={{ height: '100vh', position: 'relative' }}>
<FullCalendar
{...calendarOptions}
ref={calendarRef}
/>
</div>
</div>
);
};
export default CalendarPage;
+123
View File
@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import {
Container,
Typography,
Paper,
Box,
Button,
Alert
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { useNeoUser } from '../../contexts/NeoUserContext';
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
import { CCTeacherNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
const SettingsPage: React.FC = () => {
const { user, user_role } = useAuth();
const { userNode, workerNode } = useNeoUser();
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Check if user is a teacher (includes both email and MS teachers)
const isTeacher = user_role?.includes('teacher');
const handleTimetableUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
setIsUploading(true);
setUploadError(null);
setUploadSuccess(null);
const result = await TimetableNeoDBService.handleTimetableUpload(
event.target.files?.[0],
userNode || undefined,
workerNode?.nodeData as CCTeacherNodeProps | undefined
);
if (result.success) {
setUploadSuccess(result.message);
} else {
setUploadError(result.message);
}
} finally {
setIsUploading(false);
if (event.target) {
event.target.value = '';
}
}
};
return (
<Container maxWidth="md" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom>
Settings
</Typography>
{/* User Info Section */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
User Information
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="body1">
Email: {user?.email}
</Typography>
<Typography variant="body1">
Role: {user_role}
</Typography>
</Box>
</Paper>
{/* Timetable Upload Section - Only visible for teachers */}
{isTeacher && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Timetable Management
</Typography>
{!userNode && (
<Alert severity="info" sx={{ mb: 2 }}>
Your workspace is being set up. Some features may be limited until setup is complete.
</Alert>
)}
{uploadError && (
<Alert severity="error" sx={{ mb: 2 }}>
{uploadError}
</Alert>
)}
{uploadSuccess && (
<Alert severity="success" sx={{ mb: 2 }}>
{uploadSuccess}
</Alert>
)}
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
component="label"
disabled={isUploading || !workerNode}
color="secondary"
fullWidth
>
{isUploading ? 'Uploading...' : 'Upload Timetable'}
<input
type="file"
hidden
accept=".xlsx"
onChange={handleTimetableUpload}
disabled={isUploading}
/>
</Button>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Upload your timetable in Excel (.xlsx) format
</Typography>
</Box>
</Paper>
)}
{/* Additional settings sections can be added here */}
</Container>
);
};
export default SettingsPage;