Initial commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user