Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
BIN
View File
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import { theme } from './services/themeService';
import { AuthProvider } from './contexts/AuthContext';
import { TLDrawProvider } from './contexts/TLDrawContext';
import { UserProvider } from './contexts/UserContext';
import { NeoUserProvider } from './contexts/NeoUserContext';
import { NeoInstituteProvider } from './contexts/NeoInstituteContext';
import AppRoutes from './AppRoutes';
import React from 'react';
// Wrap the entire app in a memo to prevent unnecessary re-renders
const App = React.memo(() => (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}>
<AuthProvider>
<UserProvider>
<NeoUserProvider>
<NeoInstituteProvider>
<TLDrawProvider>
<AppRoutes />
</TLDrawProvider>
</NeoInstituteProvider>
</NeoUserProvider>
</UserProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
));
// Add display name for better debugging
App.displayName = import.meta.env.VITE_APP_NAME;
export default App;
+133
View File
@@ -0,0 +1,133 @@
import React from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import { useUser } from './contexts/UserContext';
import { useNeoUser } from './contexts/NeoUserContext';
import { useNeoInstitute } from './contexts/NeoInstituteContext';
import Layout from './pages/Layout';
import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
import AdminDashboard from './pages/auth/adminPage';
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
import DevPage from './pages/tldraw/devPage';
import TeacherPlanner from './pages/react-flow/teacherPlanner';
import MorphicPage from './pages/morphicPage';
import NotFound from './pages/user/NotFound';
import NotFoundPublic from './pages/NotFoundPublic';
import ShareHandler from './pages/tldraw/ShareHandler';
import SearxngPage from './pages/searxngPage';
import { logger } from './debugConfig';
import { CircularProgress } from '@mui/material';
const AppRoutes: React.FC = () => {
const { user, loading: isAuthLoading } = useAuth();
const { isInitialized: isUserInitialized } =
useUser();
const { isLoading: isNeoUserLoading, isInitialized: isNeoUserInitialized } =
useNeoUser();
const {
isLoading: isNeoInstituteLoading,
isInitialized: isNeoInstituteInitialized,
} = useNeoInstitute();
const location = useLocation();
// Debug log for routing
logger.debug('routing', '🔄 Rendering routes', {
hasUser: !!user,
userId: user?.id,
userEmail: user?.email,
currentPath: location.pathname,
authStatus: {
isLoading: isAuthLoading,
},
userStatus: {
isInitialized: isUserInitialized,
},
neoUserStatus: {
isLoading: isNeoUserLoading,
isInitialized: isNeoUserInitialized,
},
neoInstituteStatus: {
isLoading: isNeoInstituteLoading,
isInitialized: isNeoInstituteInitialized,
},
});
// Show loading state while initializing
if (
isAuthLoading ||
(user &&
(!isUserInitialized ||
!isNeoUserInitialized ||
!isNeoInstituteInitialized))
) {
return (
<Layout>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<CircularProgress />
</div>
</Layout>
);
}
return (
<Layout>
<Routes>
{/* Public routes */}
<Route
path="/"
element={user ? <SinglePlayerPage /> : <TLDrawCanvas />}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/share" element={<ShareHandler />} />
{/* Super Admin only routes */}
<Route
path="/admin"
element={
user?.user_type === 'admin' ? <AdminDashboard /> : <NotFound />
}
/>
{/* Authentication only routes - only render if all contexts are initialized */}
{user &&
isUserInitialized &&
isNeoUserInitialized &&
isNeoInstituteInitialized && (
<>
<Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<CCExamMarker />} />
<Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
<Route path="/dev" element={<DevPage />} />
<Route path="/single-player" element={<SinglePlayerPage />} />
<Route path="/multiplayer" element={<MultiplayerUser />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</>
)}
{/* Fallback route - use different NotFound pages based on auth state */}
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} />
</Routes>
</Layout>
);
};
export default AppRoutes;
+68
View File
@@ -0,0 +1,68 @@
import axios from 'axios';
import { logger } from './debugConfig';
// Use development backend URL if no custom URL is provided
const appProtocol = import.meta.env.VITE_APP_PROTOCOL;
const baseURL = `${appProtocol}://${import.meta.env.VITE_APP_API_URL}`;
const instance = axios.create({
baseURL,
timeout: 120000, // Increase timeout to 120 seconds for large files
headers: {
'Content-Type': 'application/json'
}
});
// Add request interceptor for logging
instance.interceptors.request.use(
(config) => {
// Don't override Content-Type if it's already set (e.g., for multipart/form-data)
if (config.headers['Content-Type'] === 'application/json' && config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
logger.debug('axios', '🔄 Outgoing request', {
method: config.method,
url: config.url,
baseURL: config.baseURL
});
return config;
},
(error) => {
logger.error('axios', '❌ Request error', error);
return Promise.reject(error);
}
);
// Add response interceptor for logging
instance.interceptors.response.use(
(response) => {
logger.debug('axios', '✅ Response received', {
status: response.status,
url: response.config.url
});
return response;
},
(error) => {
if (error.response) {
logger.error('axios', '❌ Response error', {
status: error.response.status,
url: error.config.url,
data: error.response.data
});
} else if (error.request) {
logger.error('axios', '❌ No response received', {
url: error.config.url
});
} else {
logger.error('axios', '❌ Request setup error', error.message);
}
return Promise.reject(error);
}
);
// Add type guard for Axios errors
export const {isAxiosError} = axios;
// Export the axios instance with the type guard
export default Object.assign(instance, { isAxiosError });
@@ -0,0 +1,458 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
IconButton,
Tooltip,
Box,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Button,
styled
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
ArrowForward as ArrowForwardIcon,
History as HistoryIcon,
School as SchoolIcon,
Person as PersonIcon,
AccountCircle as AccountCircleIcon,
CalendarToday as CalendarIcon,
School as TeachingIcon,
Business as BusinessIcon,
AccountTree as DepartmentIcon,
Class as ClassIcon,
ExpandMore as ExpandMoreIcon
} from '@mui/icons-material';
import { useNavigationStore } from '../../stores/navigationStore';
import { useNeoUser } from '../../contexts/NeoUserContext';
import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts';
import {
BaseContext,
ViewContext
} from '../../types/navigation';
import { logger } from '../../debugConfig';
const NavigationRoot = styled(Box)`
display: flex;
align-items: center;
gap: 8px;
height: 100%;
overflow: hidden;
`;
const NavigationControls = styled(Box)`
display: flex;
align-items: center;
gap: 4px;
`;
const ContextToggleContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0.5),
gap: theme.spacing(0.5),
'& .button-label': {
'@media (max-width: 500px)': {
display: 'none'
}
}
}));
const ContextToggleButton = styled(Button, {
shouldForwardProp: (prop) => prop !== 'active'
})<{ active?: boolean }>(({ theme, active }) => ({
minWidth: 0,
padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius,
backgroundColor: active ? theme.palette.primary.main : 'transparent',
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
textTransform: 'none',
transition: theme.transitions.create(['background-color', 'color'], {
duration: theme.transitions.duration.shorter,
}),
'&:hover': {
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
},
'@media (max-width: 500px)': {
padding: theme.spacing(0.5),
}
}));
export const GraphNavigator: React.FC = () => {
const {
context,
switchContext,
goBack,
goForward,
isLoading
} = useNavigationStore();
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [availableWidth, setAvailableWidth] = useState<number>(0);
useEffect(() => {
const calculateAvailableSpace = () => {
if (!rootRef.current) return;
// Get the header element
const header = rootRef.current.closest('.MuiToolbar-root');
if (!header) return;
// Get the title and menu elements
const title = header.querySelector('.app-title');
const menu = header.querySelector('.menu-button');
if (!title || !menu) return;
// Calculate available width
const headerWidth = header.clientWidth;
const titleWidth = title.clientWidth;
const menuWidth = menu.clientWidth;
const padding = 48; // Increased buffer space
const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding;
console.log('Available width:', newAvailableWidth); // Debug log
setAvailableWidth(newAvailableWidth);
};
// Set up ResizeObserver
const resizeObserver = new ResizeObserver(() => {
// Use requestAnimationFrame to debounce calculations
window.requestAnimationFrame(calculateAvailableSpace);
});
// Observe both the root element and the header
if (rootRef.current) {
const header = rootRef.current.closest('.MuiToolbar-root');
if (header) {
resizeObserver.observe(header);
resizeObserver.observe(rootRef.current);
}
}
// Initial calculation
calculateAvailableSpace();
return () => {
resizeObserver.disconnect();
};
}, []);
// Helper function to determine what should be visible
const getVisibility = () => {
// Adjusted thresholds and collapse order:
// 1. Navigation controls (back/forward/history) collapse first
// 2. Toggle labels collapse second
// 3. Context label collapses last
if (availableWidth < 300) {
return {
navigation: false,
contextLabel: true, // Keep context label visible longer
toggleLabels: false
};
} else if (availableWidth < 450) {
return {
navigation: false,
contextLabel: true, // Keep context label visible
toggleLabels: true
};
} else if (availableWidth < 600) {
return {
navigation: true,
contextLabel: true,
toggleLabels: true
};
}
return {
navigation: true,
contextLabel: true,
toggleLabels: true
};
};
const visibility = getVisibility();
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
setHistoryMenuAnchor(event.currentTarget);
};
const handleHistoryClose = () => {
setHistoryMenuAnchor(null);
};
const handleHistoryItemClick = (index: number) => {
const {currentIndex} = context.history;
const steps = index - currentIndex;
if (steps < 0) {
for (let i = 0; i < -steps; i++) {
goBack();
}
} else if (steps > 0) {
for (let i = 0; i < steps; i++) {
goForward();
}
}
handleHistoryClose();
};
const handleContextChange = useCallback(async (newContext: BaseContext) => {
try {
// Check if trying to access institute contexts without worker database
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
return;
}
// Check if trying to access profile contexts without user database
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
return;
}
logger.debug('navigation', '🔄 Changing main context', {
from: context.main,
to: newContext,
userDbName,
workerDbName
});
// Get default view for new context
const defaultView = getDefaultViewForContext(newContext);
// Use unified context switch with both base and extended contexts
await switchContext({
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
base: newContext,
extended: defaultView,
skipBaseContextLoad: false
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to change context:', error);
}
}, [context.main, switchContext, userDbName, workerDbName]);
// Helper function to get default view for a context
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
switch (context) {
case 'calendar':
return 'overview';
case 'teaching':
return 'overview';
case 'school':
return 'overview';
case 'department':
return 'overview';
case 'class':
return 'overview';
default:
return 'overview';
}
};
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
setContextMenuAnchor(event.currentTarget);
};
const handleContextSelect = useCallback(async (context: BaseContext) => {
setContextMenuAnchor(null);
try {
// Use unified context switch with both base and extended contexts
const contextDef = NAVIGATION_CONTEXTS[context];
const defaultExtended = contextDef?.views[0]?.id;
await switchContext({
base: context,
extended: defaultExtended
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to select context:', error);
}
}, [switchContext, userDbName, workerDbName]);
const getContextItems = useCallback(() => {
if (context.main === 'profile') {
return [
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
];
} else {
return [
{ id: 'school', label: 'School', icon: BusinessIcon },
{ id: 'department', label: 'Department', icon: DepartmentIcon },
{ id: 'class', label: 'Class', icon: ClassIcon },
];
}
}, [context.main]);
const getContextIcon = useCallback((contextType: string) => {
switch (contextType) {
case 'profile':
return <AccountCircleIcon />;
case 'calendar':
return <CalendarIcon />;
case 'teaching':
return <TeachingIcon />;
case 'school':
return <BusinessIcon />;
case 'department':
return <DepartmentIcon />;
case 'class':
return <ClassIcon />;
default:
return <AccountCircleIcon />;
}
}, []);
const isDisabled = !isNeoUserInitialized || isLoading;
const { history } = context;
const canGoBack = history.currentIndex > 0;
const canGoForward = history.currentIndex < history.nodes.length - 1;
return (
<NavigationRoot ref={rootRef}>
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
<Tooltip title="Back">
<span>
<IconButton
onClick={goBack}
disabled={!canGoBack || isDisabled}
size="small"
>
<ArrowBackIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="History">
<span>
<IconButton
onClick={handleHistoryClick}
disabled={!history.nodes.length || isDisabled}
size="small"
>
<HistoryIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Forward">
<span>
<IconButton
onClick={goForward}
disabled={!canGoForward || isDisabled}
size="small"
>
<ArrowForwardIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</NavigationControls>
{/* History Menu */}
<Menu
anchorEl={historyMenuAnchor}
open={Boolean(historyMenuAnchor)}
onClose={handleHistoryClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
{history.nodes.map((node, index) => (
<MenuItem
key={`${node.id}-${index}`}
onClick={() => handleHistoryItemClick(index)}
selected={index === history.currentIndex}
>
<ListItemIcon>
{getContextIcon(node.type)}
</ListItemIcon>
<ListItemText
primary={node.label || node.id}
secondary={node.type}
/>
</MenuItem>
))}
</Menu>
<ContextToggleContainer>
<ContextToggleButton
active={context.main === 'profile'}
onClick={() => handleContextChange('profile' as BaseContext)}
startIcon={<PersonIcon />}
disabled={isDisabled || !userDbName}
>
{visibility.toggleLabels && <span className="button-label">Profile</span>}
</ContextToggleButton>
<ContextToggleButton
active={context.main === 'institute'}
onClick={() => handleContextChange('school' as BaseContext)}
startIcon={<SchoolIcon />}
disabled={isDisabled || !workerDbName}
>
{visibility.toggleLabels && <span className="button-label">Institute</span>}
</ContextToggleButton>
</ContextToggleContainer>
<Box>
<Tooltip title={context.base}>
<span>
<Button
onClick={handleContextMenu}
disabled={isDisabled}
sx={{
minWidth: 0,
p: 0.5,
color: 'text.primary',
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
{getContextIcon(context.base)}
{visibility.contextLabel && (
<Box sx={{ ml: 1 }}>
{context.base}
</Box>
)}
<ExpandMoreIcon sx={{ ml: visibility.contextLabel ? 0.5 : 0 }} />
</Button>
</span>
</Tooltip>
</Box>
<Menu
anchorEl={contextMenuAnchor}
open={Boolean(contextMenuAnchor)}
onClose={() => setContextMenuAnchor(null)}
>
{getContextItems().map(item => (
<MenuItem
key={item.id}
onClick={() => handleContextSelect(item.id as BaseContext)}
disabled={isDisabled}
>
<ListItemIcon>
<item.icon />
</ListItemIcon>
<ListItemText primary={item.label} />
</MenuItem>
))}
</Menu>
</NavigationRoot>
);
};
@@ -0,0 +1,371 @@
import React, { useMemo } from 'react';
import { Box, IconButton, Button, Typography, styled, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
import {
NavigateBefore as NavigateBeforeIcon,
NavigateNext as NavigateNextIcon,
Today as TodayIcon,
ViewWeek as ViewWeekIcon,
DateRange as DateRangeIcon,
Event as EventIcon
} from '@mui/icons-material';
import { useNeoUser } from '../../../contexts/NeoUserContext';
import { CalendarExtendedContext } from '../../../types/navigation';
import { logger } from '../../../debugConfig';
import { useTLDraw } from '../../../contexts/TLDrawContext';
const NavigationContainer = styled(Box)(() => ({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0 8px',
minHeight: '48px',
width: '100%',
overflow: 'hidden',
'@media (max-width: 600px)': {
flexWrap: 'wrap',
padding: '4px',
gap: '4px',
},
}));
const ViewControls = styled(Box)(() => ({
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0,
}));
const NavigationSection = styled(Box)(() => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
flex: 1,
minWidth: 0, // Allows the container to shrink below its content size
'@media (max-width: 600px)': {
order: -1,
flex: '1 1 100%',
justifyContent: 'space-between',
},
}));
const TitleTypography = styled(Typography)(() => ({
color: 'var(--color-text)',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
margin: '0 8px',
}));
const ActionButtonContainer = styled(Box)(() => ({
flexShrink: 0,
'@media (max-width: 600px)': {
width: 'auto',
},
}));
const StyledIconButton = styled(IconButton)(() => ({
color: 'var(--color-text)',
transition: 'background-color 200ms ease, color 200ms ease, transform 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-hover)',
transform: 'scale(1.05)',
},
'&.Mui-disabled': {
color: 'var(--color-text-disabled)',
},
'&.active': {
color: 'var(--color-selected)',
backgroundColor: 'var(--color-selected-background)',
'&:hover': {
backgroundColor: 'var(--color-selected-hover)',
transform: 'scale(1.05)',
}
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
transition: 'transform 150ms ease',
},
}));
const ActionButton = styled(Button)(() => ({
textTransform: 'none',
padding: '6px 16px',
gap: '8px',
color: 'var(--color-text)',
transition: 'background-color 200ms ease, transform 200ms ease, box-shadow 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-hover)',
transform: 'translateY(-1px)',
},
'&:active': {
transform: 'translateY(0)',
},
'&.Mui-disabled': {
color: 'var(--color-text-disabled)',
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
color: 'inherit',
transition: 'transform 150ms ease',
},
}));
interface Props {
activeView: CalendarExtendedContext;
onViewChange: (view: CalendarExtendedContext) => void;
}
export const CalendarNavigation: React.FC<Props> = ({ activeView, onViewChange }) => {
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
// Determine mode based on TLDraw preferences
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
divider: 'var(--color-divider)',
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const {
navigateToDay,
navigateToWeek,
navigateToMonth,
navigateToYear,
currentCalendarNode,
calendarStructure
} = useNeoUser();
const handlePrevious = async () => {
if (!currentCalendarNode || !calendarStructure) return;
try {
switch (activeView) {
case 'day': {
// Find current day and get previous
const days = Object.values(calendarStructure.days);
const currentIndex = days.findIndex(d => d.id === currentCalendarNode.id);
if (currentIndex > 0) {
await navigateToDay(days[currentIndex - 1].id);
}
break;
}
case 'week': {
// Find current week and get previous
const weeks = Object.values(calendarStructure.weeks);
const currentIndex = weeks.findIndex(w => w.id === currentCalendarNode.id);
if (currentIndex > 0) {
await navigateToWeek(weeks[currentIndex - 1].id);
}
break;
}
case 'month': {
// Find current month and get previous
const months = Object.values(calendarStructure.months);
const currentIndex = months.findIndex(m => m.id === currentCalendarNode.id);
if (currentIndex > 0) {
await navigateToMonth(months[currentIndex - 1].id);
}
break;
}
case 'year': {
// Find current year and get previous
const years = calendarStructure.years;
const currentIndex = years.findIndex(y => y.id === currentCalendarNode.id);
if (currentIndex > 0) {
await navigateToYear(years[currentIndex - 1].id);
}
break;
}
}
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to previous:', error);
}
};
const handleNext = async () => {
if (!currentCalendarNode || !calendarStructure) return;
try {
switch (activeView) {
case 'day': {
// Find current day and get next
const days = Object.values(calendarStructure.days);
const currentIndex = days.findIndex(d => d.id === currentCalendarNode.id);
if (currentIndex < days.length - 1) {
await navigateToDay(days[currentIndex + 1].id);
}
break;
}
case 'week': {
// Find current week and get next
const weeks = Object.values(calendarStructure.weeks);
const currentIndex = weeks.findIndex(w => w.id === currentCalendarNode.id);
if (currentIndex < weeks.length - 1) {
await navigateToWeek(weeks[currentIndex + 1].id);
}
break;
}
case 'month': {
// Find current month and get next
const months = Object.values(calendarStructure.months);
const currentIndex = months.findIndex(m => m.id === currentCalendarNode.id);
if (currentIndex < months.length - 1) {
await navigateToMonth(months[currentIndex + 1].id);
}
break;
}
case 'year': {
// Find current year and get next
const years = calendarStructure.years;
const currentIndex = years.findIndex(y => y.id === currentCalendarNode.id);
if (currentIndex < years.length - 1) {
await navigateToYear(years[currentIndex + 1].id);
}
break;
}
}
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to next:', error);
}
};
const handleToday = async () => {
if (!calendarStructure) return;
try {
// Navigate to current day based on active view
switch (activeView) {
case 'day':
await navigateToDay(calendarStructure.currentDay);
break;
case 'week': {
const currentDay = calendarStructure.days[calendarStructure.currentDay];
if (currentDay) {
const week = Object.values(calendarStructure.weeks)
.find(w => w.days.includes(currentDay));
if (week) {
await navigateToWeek(week.id);
}
}
break;
}
case 'month': {
const currentDay = calendarStructure.days[calendarStructure.currentDay];
if (currentDay) {
const month = Object.values(calendarStructure.months)
.find(m => m.days.includes(currentDay));
if (month) {
await navigateToMonth(month.id);
}
}
break;
}
case 'year': {
const currentDay = calendarStructure.days[calendarStructure.currentDay];
if (currentDay) {
const month = Object.values(calendarStructure.months)
.find(m => m.days.includes(currentDay));
if (month) {
const year = calendarStructure.years
.find(y => y.months.includes(month));
if (year) {
await navigateToYear(year.id);
}
}
}
break;
}
}
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to today:', error);
}
};
return (
<ThemeProvider theme={theme}>
<NavigationContainer>
<NavigationSection>
<StyledIconButton
size="small"
onClick={handlePrevious}
disabled={!currentCalendarNode || !calendarStructure}
>
<NavigateBeforeIcon />
</StyledIconButton>
{currentCalendarNode && (
<TitleTypography
variant="subtitle2"
>
{currentCalendarNode.title}
</TitleTypography>
)}
<StyledIconButton
size="small"
onClick={handleNext}
disabled={!currentCalendarNode || !calendarStructure}
>
<NavigateNextIcon />
</StyledIconButton>
</NavigationSection>
<ViewControls>
<StyledIconButton
size="small"
onClick={() => onViewChange('day')}
className={activeView === 'day' ? 'active' : ''}
>
<TodayIcon />
</StyledIconButton>
<StyledIconButton
size="small"
onClick={() => onViewChange('week')}
className={activeView === 'week' ? 'active' : ''}
>
<ViewWeekIcon />
</StyledIconButton>
<StyledIconButton
size="small"
onClick={() => onViewChange('month')}
className={activeView === 'month' ? 'active' : ''}
>
<DateRangeIcon />
</StyledIconButton>
<StyledIconButton
size="small"
onClick={() => onViewChange('year')}
className={activeView === 'year' ? 'active' : ''}
>
<EventIcon />
</StyledIconButton>
</ViewControls>
<ActionButtonContainer>
<ActionButton
size="small"
startIcon={<TodayIcon />}
onClick={handleToday}
disabled={!calendarStructure}
>
Today
</ActionButton>
</ActionButtonContainer>
</NavigationContainer>
</ThemeProvider>
);
};
@@ -0,0 +1,361 @@
import React from 'react';
import { Box, IconButton, Typography, styled, Tabs, Tab } from '@mui/material';
import {
Schedule as ScheduleIcon,
Book as JournalIcon,
EventNote as PlannerIcon,
Class as ClassIcon,
MenuBook as LessonIcon,
NavigateBefore as NavigateBeforeIcon,
NavigateNext as NavigateNextIcon,
Dashboard as DashboardIcon
} from '@mui/icons-material';
import { useNeoUser } from '../../../contexts/NeoUserContext';
import { TeacherExtendedContext } from '../../../types/navigation';
import { logger } from '../../../debugConfig';
import { useTLDraw } from '../../../contexts/TLDrawContext';
const NavigationContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(0, 2),
}));
const ViewControls = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
}));
const StyledIconButton = styled(IconButton, {
shouldForwardProp: prop => prop !== 'isDarkMode'
})<{ isDarkMode?: boolean }>(({ theme, isDarkMode }) => ({
color: isDarkMode ? theme.palette.text.primary : theme.palette.text.secondary,
transition: theme.transitions.create(['background-color', 'color', 'transform'], {
duration: theme.transitions.duration.shorter,
}),
'&:hover': {
backgroundColor: theme.palette.action.hover,
transform: 'scale(1.05)',
},
'&.Mui-disabled': {
color: theme.palette.action.disabled,
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
}));
const StyledTabs = styled(Tabs, {
shouldForwardProp: prop => prop !== 'isDarkMode'
})<{ isDarkMode?: boolean }>(({ theme, isDarkMode }) => ({
minHeight: 'unset',
'& .MuiTab-root': {
minHeight: 'unset',
padding: theme.spacing(1),
textTransform: 'none',
fontSize: '0.875rem',
color: isDarkMode ? theme.palette.text.primary : theme.palette.text.secondary,
transition: theme.transitions.create(['color', 'background-color', 'box-shadow'], {
duration: theme.transitions.duration.shorter,
}),
'&:hover': {
backgroundColor: theme.palette.action.hover,
color: theme.palette.primary.main,
},
'&.Mui-selected': {
color: theme.palette.primary.main,
'&:hover': {
backgroundColor: theme.palette.action.selected,
},
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
marginBottom: theme.spacing(0.5),
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
'&:hover .MuiSvgIcon-root': {
transform: 'scale(1.1)',
},
},
'& .MuiTabs-indicator': {
transition: theme.transitions.create(['width', 'left'], {
duration: theme.transitions.duration.standard,
easing: theme.transitions.easing.easeInOut,
}),
},
}));
interface Props {
activeView: TeacherExtendedContext;
onViewChange: (view: TeacherExtendedContext) => void;
}
export const TeacherNavigation: React.FC<Props> = ({ activeView, onViewChange }) => {
const { tldrawPreferences } = useTLDraw();
const isDarkMode = tldrawPreferences?.colorScheme === 'dark';
const {
navigateToTimetable,
navigateToClass,
navigateToLesson,
navigateToJournal,
navigateToPlanner,
currentWorkerNode,
workerStructure
} = useNeoUser();
const handlePrevious = async () => {
if (!currentWorkerNode || !workerStructure) return;
try {
switch (activeView) {
case 'overview': {
// Overview doesn't have navigation
break;
}
case 'timetable': {
// Find current timetable and get previous
const deptId = Object.keys(workerStructure.timetables).find(
deptId => workerStructure.timetables[deptId].some(t => t.id === currentWorkerNode.id)
);
if (deptId) {
const timetables = workerStructure.timetables[deptId];
const currentIndex = timetables.findIndex(t => t.id === currentWorkerNode.id);
if (currentIndex > 0) {
await navigateToTimetable(timetables[currentIndex - 1].id);
}
}
break;
}
case 'classes': {
// Find current class and get previous
const deptId = Object.keys(workerStructure.classes).find(
deptId => workerStructure.classes[deptId].some(c => c.id === currentWorkerNode.id)
);
if (deptId) {
const classes = workerStructure.classes[deptId];
const currentIndex = classes.findIndex(c => c.id === currentWorkerNode.id);
if (currentIndex > 0) {
await navigateToClass(classes[currentIndex - 1].id);
}
}
break;
}
case 'lessons': {
// Find current lesson and get previous
const deptId = Object.keys(workerStructure.lessons).find(
deptId => workerStructure.lessons[deptId].some(l => l.id === currentWorkerNode.id)
);
if (deptId) {
const lessons = workerStructure.lessons[deptId];
const currentIndex = lessons.findIndex(l => l.id === currentWorkerNode.id);
if (currentIndex > 0) {
await navigateToLesson(lessons[currentIndex - 1].id);
}
}
break;
}
case 'journal': {
// Find current journal and get previous
const deptId = Object.keys(workerStructure.journals).find(
deptId => workerStructure.journals[deptId].some(j => j.id === currentWorkerNode.id)
);
if (deptId) {
const journals = workerStructure.journals[deptId];
const currentIndex = journals.findIndex(j => j.id === currentWorkerNode.id);
if (currentIndex > 0) {
await navigateToJournal(journals[currentIndex - 1].id);
}
}
break;
}
case 'planner': {
// Find current planner and get previous
const deptId = Object.keys(workerStructure.planners).find(
deptId => workerStructure.planners[deptId].some(p => p.id === currentWorkerNode.id)
);
if (deptId) {
const planners = workerStructure.planners[deptId];
const currentIndex = planners.findIndex(p => p.id === currentWorkerNode.id);
if (currentIndex > 0) {
await navigateToPlanner(planners[currentIndex - 1].id);
}
}
break;
}
}
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to previous:', error);
}
};
const handleNext = async () => {
if (!currentWorkerNode || !workerStructure) return;
try {
switch (activeView) {
case 'overview': {
// Overview doesn't have navigation
break;
}
case 'timetable': {
// Find current timetable and get next
const deptId = Object.keys(workerStructure.timetables).find(
deptId => workerStructure.timetables[deptId].some(t => t.id === currentWorkerNode.id)
);
if (deptId) {
const timetables = workerStructure.timetables[deptId];
const currentIndex = timetables.findIndex(t => t.id === currentWorkerNode.id);
if (currentIndex < timetables.length - 1) {
await navigateToTimetable(timetables[currentIndex + 1].id);
}
}
break;
}
case 'classes': {
// Find current class and get next
const deptId = Object.keys(workerStructure.classes).find(
deptId => workerStructure.classes[deptId].some(c => c.id === currentWorkerNode.id)
);
if (deptId) {
const classes = workerStructure.classes[deptId];
const currentIndex = classes.findIndex(c => c.id === currentWorkerNode.id);
if (currentIndex < classes.length - 1) {
await navigateToClass(classes[currentIndex + 1].id);
}
}
break;
}
case 'lessons': {
// Find current lesson and get next
const deptId = Object.keys(workerStructure.lessons).find(
deptId => workerStructure.lessons[deptId].some(l => l.id === currentWorkerNode.id)
);
if (deptId) {
const lessons = workerStructure.lessons[deptId];
const currentIndex = lessons.findIndex(l => l.id === currentWorkerNode.id);
if (currentIndex < lessons.length - 1) {
await navigateToLesson(lessons[currentIndex + 1].id);
}
}
break;
}
case 'journal': {
// Find current journal and get next
const deptId = Object.keys(workerStructure.journals).find(
deptId => workerStructure.journals[deptId].some(j => j.id === currentWorkerNode.id)
);
if (deptId) {
const journals = workerStructure.journals[deptId];
const currentIndex = journals.findIndex(j => j.id === currentWorkerNode.id);
if (currentIndex < journals.length - 1) {
await navigateToJournal(journals[currentIndex + 1].id);
}
}
break;
}
case 'planner': {
// Find current planner and get next
const deptId = Object.keys(workerStructure.planners).find(
deptId => workerStructure.planners[deptId].some(p => p.id === currentWorkerNode.id)
);
if (deptId) {
const planners = workerStructure.planners[deptId];
const currentIndex = planners.findIndex(p => p.id === currentWorkerNode.id);
if (currentIndex < planners.length - 1) {
await navigateToPlanner(planners[currentIndex + 1].id);
}
}
break;
}
}
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to next:', error);
}
};
return (
<NavigationContainer>
<StyledTabs
value={activeView}
onChange={(_, value) => onViewChange(value as TeacherExtendedContext)}
variant="scrollable"
scrollButtons="auto"
isDarkMode={isDarkMode}
>
<Tab
value="overview"
icon={<DashboardIcon />}
label="Overview"
/>
<Tab
value="timetable"
icon={<ScheduleIcon />}
label="Timetable"
/>
<Tab
value="classes"
icon={<ClassIcon />}
label="Classes"
/>
<Tab
value="lessons"
icon={<LessonIcon />}
label="Lessons"
/>
<Tab
value="journal"
icon={<JournalIcon />}
label="Journal"
/>
<Tab
value="planner"
icon={<PlannerIcon />}
label="Planner"
/>
</StyledTabs>
<Box sx={{ flex: 1 }} />
<ViewControls>
<StyledIconButton
size="small"
onClick={handlePrevious}
disabled={!currentWorkerNode || !workerStructure}
isDarkMode={isDarkMode}
>
<NavigateBeforeIcon />
</StyledIconButton>
{currentWorkerNode && (
<Typography
variant="subtitle2"
component="span"
sx={{
mx: 2,
color: 'text.primary',
fontWeight: 500
}}
>
{currentWorkerNode.title}
</Typography>
)}
<StyledIconButton
size="small"
onClick={handleNext}
disabled={!currentWorkerNode || !workerStructure}
isDarkMode={isDarkMode}
>
<NavigateNextIcon />
</StyledIconButton>
</ViewControls>
</NavigationContainer>
);
};
@@ -0,0 +1,60 @@
import React from 'react';
import { Box, Typography, styled, Tabs, Tab } from '@mui/material';
import {
AccountCircle as ProfileIcon,
Book as JournalIcon,
EventNote as PlannerIcon
} from '@mui/icons-material';
import { useNeoUser } from '../../../contexts/NeoUserContext';
import { UserExtendedContext } from '../../../types/navigation';
const NavigationContainer = styled(Box)`
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
`;
interface Props {
activeView: UserExtendedContext;
onViewChange: (view: UserExtendedContext) => void;
}
export const UserNavigation: React.FC<Props> = ({ activeView, onViewChange }) => {
const { currentWorkerNode } = useNeoUser();
return (
<NavigationContainer>
<Tabs
value={activeView}
onChange={(_, value) => onViewChange(value as UserExtendedContext)}
variant="scrollable"
scrollButtons="auto"
>
<Tab
value="profile"
icon={<ProfileIcon />}
label="Profile"
/>
<Tab
value="journal"
icon={<JournalIcon />}
label="Journal"
/>
<Tab
value="planner"
icon={<PlannerIcon />}
label="Planner"
/>
</Tabs>
<Box sx={{ flex: 1 }} />
{currentWorkerNode && (
<Typography variant="subtitle2" sx={{ px: 2 }}>
{currentWorkerNode.label}
</Typography>
)}
</NavigationContainer>
);
};
@@ -0,0 +1,3 @@
export { CalendarNavigation } from './CalendarNavigation';
export { TeacherNavigation } from './TeacherNavigation';
export { UserNavigation } from './UserNavigation';
+211
View File
@@ -0,0 +1,211 @@
import { ContextDefinition } from '../types/navigation';
export const NAVIGATION_CONTEXTS: Record<string, ContextDefinition> = {
// Personal Contexts
profile: {
id: 'profile',
icon: 'Person',
label: 'User Profile',
description: 'Personal workspace and settings',
defaultNodeId: 'user-root',
views: [
{
id: 'overview',
icon: 'Dashboard',
label: 'Overview',
description: 'View your profile overview'
},
{
id: 'settings',
icon: 'Settings',
label: 'Settings',
description: 'Manage your preferences'
},
{
id: 'history',
icon: 'History',
label: 'History',
description: 'View your activity history'
},
{
id: 'journal',
icon: 'Book',
label: 'Journal',
description: 'Your personal journal'
},
{
id: 'planner',
icon: 'Event',
label: 'Planner',
description: 'Plan your activities'
}
]
},
calendar: {
id: 'calendar',
icon: 'CalendarToday',
label: 'Calendar',
description: 'Calendar navigation and events',
defaultNodeId: 'calendar-root',
views: [
{
id: 'overview',
icon: 'Dashboard',
label: 'Overview',
description: 'Calendar overview'
},
{
id: 'day',
icon: 'Today',
label: 'Day View',
description: 'Navigate by day'
},
{
id: 'week',
icon: 'ViewWeek',
label: 'Week View',
description: 'Navigate by week'
},
{
id: 'month',
icon: 'DateRange',
label: 'Month View',
description: 'Navigate by month'
},
{
id: 'year',
icon: 'Event',
label: 'Year View',
description: 'Navigate by year'
}
]
},
teaching: {
id: 'teaching',
icon: 'School',
label: 'Teaching',
description: 'Teaching workspace',
defaultNodeId: 'teacher-root',
views: [
{
id: 'overview',
icon: 'Dashboard',
label: 'Overview',
description: 'Teaching overview'
},
{
id: 'timetable',
icon: 'Schedule',
label: 'Timetable',
description: 'View your teaching schedule'
},
{
id: 'classes',
icon: 'Class',
label: 'Classes',
description: 'Manage your classes'
},
{
id: 'lessons',
icon: 'Book',
label: 'Lessons',
description: 'Plan and view lessons'
},
{
id: 'journal',
icon: 'Book',
label: 'Journal',
description: 'Your teaching journal'
},
{
id: 'planner',
icon: 'Event',
label: 'Planner',
description: 'Plan your teaching activities'
}
]
},
// Institutional Contexts
school: {
id: 'school',
icon: 'Business',
label: 'School',
description: 'School management',
defaultNodeId: 'school-root',
views: [
{
id: 'overview',
icon: 'Dashboard',
label: 'Overview',
description: 'School overview'
},
{
id: 'departments',
icon: 'AccountTree',
label: 'Departments',
description: 'View departments'
},
{
id: 'staff',
icon: 'People',
label: 'Staff',
description: 'Staff directory'
}
]
},
department: {
id: 'department',
icon: 'AccountTree',
label: 'Department',
description: 'Department management',
defaultNodeId: 'department-root',
views: [
{
id: 'overview',
icon: 'Dashboard',
label: 'Overview',
description: 'Department overview'
},
{
id: 'teachers',
icon: 'People',
label: 'Teachers',
description: 'Department teachers'
},
{
id: 'subjects',
icon: 'Subject',
label: 'Subjects',
description: 'Department subjects'
}
]
},
class: {
id: 'class',
icon: 'Class',
label: 'Class',
description: 'Class management',
defaultNodeId: 'class-root',
views: [
{
id: 'overview',
icon: 'Dashboard',
label: 'Overview',
description: 'Class overview'
},
{
id: 'students',
icon: 'People',
label: 'Students',
description: 'Class students'
},
{
id: 'timetable',
icon: 'Schedule',
label: 'Timetable',
description: 'Class schedule'
}
]
}
};
+157
View File
@@ -0,0 +1,157 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient';
export interface AuthContextType {
user: CCUser | null;
user_role: string | null;
loading: boolean;
error: Error | null;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
clearError: () => void;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
user_role: null,
loading: true,
error: null,
signIn: async () => {},
signOut: async () => {},
clearError: () => {}
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [user, setUser] = useState<CCUser | null>(null);
const [user_role, setUserRole] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const loadUser = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const metadata = user.user_metadata as CCUserMetadata;
setUser({
id: user.id,
email: user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
school_db_name: 'cc.institutes.development.default',
created_at: user.created_at,
updated_at: user.updated_at
});
setUserRole(metadata.user_role || null);
} else {
setUser(null);
}
} catch (error) {
logger.error('auth-context', '❌ Failed to load user', { error });
setError(error instanceof Error ? error : new Error('Failed to load user'));
} finally {
setLoading(false);
}
};
loadUser();
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' && session?.user) {
const metadata = session.user.user_metadata as CCUserMetadata;
setUser({
id: session.user.id,
email: session.user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
school_db_name: 'cc.institutes.development.default',
created_at: session.user.created_at,
updated_at: session.user.updated_at
});
} else if (event === 'SIGNED_OUT') {
setUser(null);
}
});
return () => {
subscription.unsubscribe();
};
}, []);
const signIn = async (email: string, password: string) => {
try {
setLoading(true);
const { data, error: signInError } = await supabase.auth.signInWithPassword({
email,
password
});
if (signInError) throw signInError;
if (data.user) {
const metadata = data.user.user_metadata as CCUserMetadata;
setUser({
id: data.user.id,
email: data.user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
school_db_name: 'cc.institutes.development.default',
created_at: data.user.created_at,
updated_at: data.user.updated_at
});
}
} catch (error) {
logger.error('auth-context', '❌ Sign in failed', { error });
setError(error instanceof Error ? error : new Error('Sign in failed'));
throw error;
} finally {
setLoading(false);
}
};
const signOut = async () => {
try {
setLoading(true);
await authService.logout();
setUser(null);
navigate('/');
} catch (error) {
logger.error('auth-context', '❌ Sign out failed', { error });
setError(error instanceof Error ? error : new Error('Sign out failed'));
throw error;
} finally {
setLoading(false);
}
};
const clearError = () => setError(null);
return (
<AuthContext.Provider
value={{
user,
user_role,
loading,
error,
signIn,
signOut,
clearError
}}
>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
+92
View File
@@ -0,0 +1,92 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useAuth } from './AuthContext';
import { useUser } from './UserContext';
import { SchoolNeoDBService } from '../services/graph/schoolNeoDBService';
import { CCSchoolNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../debugConfig';
export interface NeoInstituteContextType {
schoolNode: CCSchoolNodeProps | null;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
}
const NeoInstituteContext = createContext<NeoInstituteContextType>({
schoolNode: null,
isLoading: true,
isInitialized: false,
error: null
});
export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const { profile, isInitialized: isUserInitialized } = useUser();
const [schoolNode, setSchoolNode] = useState<CCSchoolNodeProps | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Wait for user profile to be ready
if (!isUserInitialized) {
logger.debug('neo-institute-context', '⏳ Waiting for user initialization...');
return;
}
// If no profile or no worker database, mark as initialized with no data
if (!profile || !profile.school_db_name) {
setIsLoading(false);
setIsInitialized(true);
return;
}
const loadSchoolNode = async () => {
try {
setIsLoading(true);
logger.debug('neo-institute-context', '🔄 Loading school node', {
schoolDbName: profile.school_db_name,
userEmail: user?.email
});
const node = await SchoolNeoDBService.getSchoolNode(profile.school_db_name);
if (node) {
setSchoolNode(node);
logger.debug('neo-institute-context', '✅ School node loaded', {
schoolId: node.unique_id,
dbName: profile.school_db_name
});
} else {
logger.warn('neo-institute-context', '⚠️ No school node found');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load school node';
logger.error('neo-institute-context', '❌ Failed to load school node', {
error: errorMessage,
schoolDbName: profile.school_db_name
});
setError(errorMessage);
} finally {
setIsLoading(false);
setIsInitialized(true);
}
};
loadSchoolNode();
}, [user?.email, profile, isUserInitialized]);
return (
<NeoInstituteContext.Provider value={{
schoolNode,
isLoading,
isInitialized,
error
}}>
{children}
</NeoInstituteContext.Provider>
);
};
export const useNeoInstitute = () => useContext(NeoInstituteContext);
+624
View File
@@ -0,0 +1,624 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useAuth } from './AuthContext';
import { useUser } from './UserContext';
import { logger } from '../debugConfig';
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { CalendarStructure, WorkerStructure } from '../types/navigation';
import { useNavigationStore } from '../stores/navigationStore';
// Core Node Types
export interface CalendarNode {
id: string;
label: string;
title: string;
tldraw_snapshot: string;
type?: CCCalendarNodeProps['__primarylabel__'];
nodeData?: CCCalendarNodeProps;
}
export interface WorkerNode {
id: string;
label: string;
title: string;
tldraw_snapshot: string;
type?: CCUserTeacherTimetableNodeProps['__primarylabel__'];
nodeData?: CCUserTeacherTimetableNodeProps;
}
// Calendar Structure Types
export interface CalendarDay {
id: string;
date: string;
title: string;
}
export interface CalendarWeek {
id: string;
title: string;
days: { id: string }[];
startDate: string;
endDate: string;
}
export interface CalendarMonth {
id: string;
title: string;
days: { id: string }[];
weeks: { id: string }[];
year: string;
month: string;
}
export interface CalendarYear {
id: string;
title: string;
months: { id: string }[];
year: string;
}
// Worker Structure Types
export interface TimetableEntry {
id: string;
title: string;
type: string;
startTime: string;
endTime: string;
}
export interface ClassEntry {
id: string;
title: string;
type: string;
}
export interface LessonEntry {
id: string;
title: string;
type: string;
}
interface NeoUserContextType {
userNode: CCUserNodeProps | null;
calendarNode: CalendarNode | null;
workerNode: WorkerNode | null;
userDbName: string | null;
workerDbName: string | null;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
// Calendar Navigation
navigateToDay: (id: string) => Promise<void>;
navigateToWeek: (id: string) => Promise<void>;
navigateToMonth: (id: string) => Promise<void>;
navigateToYear: (id: string) => Promise<void>;
currentCalendarNode: CalendarNode | null;
calendarStructure: CalendarStructure | null;
// Worker Navigation
navigateToTimetable: (id: string) => Promise<void>;
navigateToJournal: (id: string) => Promise<void>;
navigateToPlanner: (id: string) => Promise<void>;
navigateToClass: (id: string) => Promise<void>;
navigateToLesson: (id: string) => Promise<void>;
currentWorkerNode: WorkerNode | null;
workerStructure: WorkerStructure | null;
}
const NeoUserContext = createContext<NeoUserContextType>({
userNode: null,
calendarNode: null,
workerNode: null,
userDbName: null,
workerDbName: null,
isLoading: false,
isInitialized: false,
error: null,
navigateToDay: async () => {},
navigateToWeek: async () => {},
navigateToMonth: async () => {},
navigateToYear: async () => {},
navigateToTimetable: async () => {},
navigateToJournal: async () => {},
navigateToPlanner: async () => {},
navigateToClass: async () => {},
navigateToLesson: async () => {},
currentCalendarNode: null,
currentWorkerNode: null,
calendarStructure: null,
workerStructure: null
});
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const { profile, isInitialized: isUserInitialized } = useUser();
const navigationStore = useNavigationStore();
const [userNode, setUserNode] = useState<CCUserNodeProps | null>(null);
const [calendarNode] = useState<CalendarNode | null>(null);
const [workerNode] = useState<WorkerNode | null>(null);
const [currentCalendarNode, setCurrentCalendarNode] = useState<CalendarNode | null>(null);
const [currentWorkerNode, setCurrentWorkerNode] = useState<WorkerNode | null>(null);
const [calendarStructure] = useState<CalendarStructure | null>(null);
const [workerStructure] = useState<WorkerStructure | null>(null);
const [userDbName, setUserDbName] = useState<string | null>(null);
const [workerDbName, setWorkerDbName] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);
// Use ref for initialization tracking to prevent re-renders
const initializationRef = React.useRef({
hasStarted: false,
isComplete: false
});
// Add base properties for node data
const getBaseNodeProps = () => ({
title: '',
w: 200,
h: 200,
headerColor: '#000000',
backgroundColor: '#ffffff',
isLocked: false,
__primarylabel__: 'UserTeacherTimetable',
unique_id: '',
tldraw_snapshot: '',
created: new Date().toISOString(),
merged: new Date().toISOString(),
state: {
parentId: null,
isPageChild: false,
hasChildren: false,
bindings: [],
},
defaultComponent: true,
});
// Initialize context when dependencies are ready
useEffect(() => {
if (!isUserInitialized || !profile || isInitialized || initializationRef.current.hasStarted) {
return;
}
const initializeContext = async () => {
try {
initializationRef.current.hasStarted = true;
setIsLoading(true);
setError(null);
// Set database names
const userDb = profile.user_db_name || (user?.email ?
`cc.users.${user.email.replace('@', 'at').replace(/\./g, 'dot')}` : null);
if (!userDb) {
throw new Error('No user database name available');
}
// Initialize user node in profile context
logger.debug('neo-user-context', '🔄 Starting context initialization');
// Initialize user node
await navigationStore.switchContext({
main: 'profile',
base: 'profile',
extended: 'overview'
}, userDb, profile.school_db_name);
const userNavigationNode = navigationStore.context.node;
if (userNavigationNode?.data) {
const userNodeData: CCUserNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'User',
unique_id: userNavigationNode.id,
tldraw_snapshot: userNavigationNode.tldraw_snapshot || '',
title: String(userNavigationNode.data?.user_name || 'User'),
user_name: String(userNavigationNode.data?.user_name || 'User'),
user_email: user?.email || '',
user_type: 'User',
user_id: userNavigationNode.id,
worker_node_data: JSON.stringify(userNavigationNode.data || {})
};
setUserNode(userNodeData);
}
// Set final state
setUserDbName(userDb);
setWorkerDbName(profile.school_db_name);
setIsInitialized(true);
setIsLoading(false);
initializationRef.current.isComplete = true;
logger.debug('neo-user-context', '✅ Context initialization complete');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize user context';
logger.error('neo-user-context', '❌ Failed to initialize context', { error: errorMessage });
setError(errorMessage);
setIsLoading(false);
setIsInitialized(true);
initializationRef.current.isComplete = true;
}
};
initializeContext();
}, [user?.email, profile, isUserInitialized, navigationStore, isInitialized]);
// Calendar Navigation Functions
const navigateToDay = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'day'
}, userDbName, workerDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarDay',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
name: node.label,
calendar_type: 'day',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'CalendarDay',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to day');
} finally {
setIsLoading(false);
}
};
const navigateToWeek = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'week'
}, userDbName, workerDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarWeek',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
name: node.label,
calendar_type: 'week',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'CalendarWeek',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to week');
} finally {
setIsLoading(false);
}
};
const navigateToMonth = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'month'
}, userDbName, workerDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarMonth',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
name: node.label,
calendar_type: 'month',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'CalendarMonth',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to month');
} finally {
setIsLoading(false);
}
};
const navigateToYear = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'year'
}, userDbName, workerDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarYear',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
name: node.label,
calendar_type: 'year',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'CalendarYear',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to year');
} finally {
setIsLoading(false);
}
};
// Worker Navigation Functions
const navigateToTimetable = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'timetable'
}, userDbName, workerDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
};
setCurrentWorkerNode({
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to timetable');
} finally {
setIsLoading(false);
}
};
const navigateToJournal = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'journal'
}, userDbName, workerDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
};
setCurrentWorkerNode({
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to journal');
} finally {
setIsLoading(false);
}
};
const navigateToPlanner = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'planner'
}, userDbName, workerDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
};
setCurrentWorkerNode({
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to planner');
} finally {
setIsLoading(false);
}
};
const navigateToClass = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'classes'
}, userDbName, workerDbName);
await navigationStore.navigate(id, userDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: node.id
};
setCurrentWorkerNode({
id: node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to class');
} finally {
setIsLoading(false);
}
};
const navigateToLesson = async (id: string) => {
if (!userDbName) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'lessons'
}, userDbName, workerDbName);
await navigationStore.navigate(id, userDbName);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: node.id
};
setCurrentWorkerNode({
id: node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to lesson');
} finally {
setIsLoading(false);
}
};
return (
<NeoUserContext.Provider value={{
userNode,
calendarNode,
workerNode,
userDbName,
workerDbName,
isLoading,
isInitialized,
error,
navigateToDay,
navigateToWeek,
navigateToMonth,
navigateToYear,
navigateToTimetable,
navigateToJournal,
navigateToPlanner,
navigateToClass,
navigateToLesson,
currentCalendarNode,
currentWorkerNode,
calendarStructure,
workerStructure
}}>
{children}
</NeoUserContext.Provider>
);
};
export const useNeoUser = () => useContext(NeoUserContext);
+221
View File
@@ -0,0 +1,221 @@
import React, { ReactNode, createContext, useContext, useState, useCallback } from 'react';
import { TLUserPreferences, TLEditorSnapshot, TLStore, getSnapshot, loadSnapshot, Editor } from '@tldraw/tldraw';
import { storageService, StorageKeys } from '../services/auth/localStorageService';
import { LoadingState } from '../services/tldraw/snapshotService';
import { SharedStoreService } from '../services/tldraw/sharedStoreService';
import { logger } from '../debugConfig';
import { PresentationService } from '../services/tldraw/presentationService';
interface TLDrawContextType {
tldrawPreferences: TLUserPreferences | null;
tldrawUserFilePath: string | null;
localSnapshot: Partial<TLEditorSnapshot> | null;
presentationMode: boolean;
sharedStore: SharedStoreService | null;
connectionStatus: 'online' | 'offline' | 'error';
presentationService: PresentationService | null;
setTldrawPreferences: (preferences: TLUserPreferences | null) => void;
setTldrawUserFilePath: (path: string | null) => void;
handleLocalSnapshot: (
action: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void
) => Promise<void>;
togglePresentationMode: (editor?: Editor) => void;
initializePreferences: (userId: string) => void;
setSharedStore: (store: SharedStoreService | null) => void;
setConnectionStatus: (status: 'online' | 'offline' | 'error') => void;
}
const TLDrawContext = createContext<TLDrawContextType>({
tldrawPreferences: null,
tldrawUserFilePath: null,
localSnapshot: null,
presentationMode: false,
sharedStore: null,
connectionStatus: 'online',
presentationService: null,
setTldrawPreferences: () => {},
setTldrawUserFilePath: () => {},
handleLocalSnapshot: async () => {},
togglePresentationMode: () => {},
initializePreferences: () => {},
setSharedStore: () => {},
setConnectionStatus: () => {}
});
export const TLDrawProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [tldrawPreferences, setTldrawPreferencesState] = useState<TLUserPreferences | null>(
storageService.get(StorageKeys.TLDRAW_PREFERENCES)
);
const [tldrawUserFilePath, setTldrawUserFilePathState] = useState<string | null>(
storageService.get(StorageKeys.TLDRAW_FILE_PATH)
);
const [localSnapshot, setLocalSnapshot] = useState<Partial<TLEditorSnapshot> | null>(
storageService.get(StorageKeys.LOCAL_SNAPSHOT)
);
const [presentationMode, setPresentationMode] = useState<boolean>(
storageService.get(StorageKeys.PRESENTATION_MODE) || false
);
const [sharedStore, setSharedStore] = useState<SharedStoreService | null>(null);
const [connectionStatus, setConnectionStatus] = useState<'online' | 'offline' | 'error'>('online');
const [presentationService, setPresentationService] = useState<PresentationService | null>(null);
const initializePreferences = useCallback((userId: string) => {
logger.debug('tldraw-context', '🔄 Initializing TLDraw preferences');
const storedPrefs = storageService.get(StorageKeys.TLDRAW_PREFERENCES);
if (storedPrefs) {
logger.debug('tldraw-context', '📥 Found stored preferences');
setTldrawPreferencesState(storedPrefs);
return;
}
// Create default preferences if none exist
const defaultPrefs: TLUserPreferences = {
id: userId,
name: 'User',
color: `hsl(${Math.random() * 360}, 70%, 50%)`,
locale: 'en',
colorScheme: 'system',
isSnapMode: false,
isWrapMode: false,
isDynamicSizeMode: false,
isPasteAtCursorMode: false,
animationSpeed: 1,
edgeScrollSpeed: 1
};
logger.debug('tldraw-context', '📝 Creating default preferences');
storageService.set(StorageKeys.TLDRAW_PREFERENCES, defaultPrefs);
setTldrawPreferencesState(defaultPrefs);
}, []);
const setTldrawPreferences = useCallback((preferences: TLUserPreferences | null) => {
logger.debug('tldraw-context', '🔄 Setting TLDraw preferences', { preferences });
if (preferences) {
storageService.set(StorageKeys.TLDRAW_PREFERENCES, preferences);
} else {
storageService.remove(StorageKeys.TLDRAW_PREFERENCES);
}
setTldrawPreferencesState(preferences);
}, []);
const setTldrawUserFilePath = (path: string | null) => {
logger.debug('tldraw-context', '🔄 Setting TLDraw user file path');
if (path) {
storageService.set(StorageKeys.TLDRAW_FILE_PATH, path);
} else {
storageService.remove(StorageKeys.TLDRAW_FILE_PATH);
}
setTldrawUserFilePathState(path);
};
const handleLocalSnapshot = useCallback(async (
action: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void
): Promise<void> => {
if (!store) {
setLoadingState({ status: 'error', error: 'Store not initialized' });
return;
}
try {
if (sharedStore) {
if (action === 'put') {
const snapshot = getSnapshot(store);
await sharedStore.saveSnapshot(snapshot, setLoadingState);
} else if (action === 'get') {
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
if (savedSnapshot) {
await sharedStore.loadSnapshot(savedSnapshot, setLoadingState);
}
}
}
else if (action === 'put') {
logger.debug('tldraw-context', '💾 Putting snapshot into local storage');
const snapshot = getSnapshot(store);
logger.debug('tldraw-context', '📦 Snapshot:', snapshot);
setLocalSnapshot(snapshot);
storageService.set(StorageKeys.LOCAL_SNAPSHOT, snapshot);
setLoadingState({ status: 'ready', error: '' });
}
else if (action === 'get') {
logger.debug('tldraw-context', '📂 Getting snapshot from local storage');
setLoadingState({ status: 'loading', error: '' });
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
if (savedSnapshot && savedSnapshot.document && savedSnapshot.session) {
try {
logger.debug('tldraw-context', '📥 Loading snapshot into editor');
loadSnapshot(store, savedSnapshot);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('tldraw-context', '❌ Failed to load snapshot:', error);
store.clear();
setLoadingState({ status: 'error', error: 'Failed to load snapshot' });
}
} else {
logger.debug('tldraw-context', '⚠️ No valid snapshot found in local storage');
setLoadingState({ status: 'ready', error: '' });
}
}
} catch (error) {
logger.error('tldraw-context', '❌ Error handling local snapshot:', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}, [sharedStore]);
const togglePresentationMode = useCallback((editor?: Editor) => {
logger.debug('tldraw-context', '🔄 Toggling presentation mode');
setPresentationMode(prev => {
const newValue = !prev;
storageService.set(StorageKeys.PRESENTATION_MODE, newValue);
if (newValue && editor) {
// Starting presentation mode
logger.info('presentation', '🎥 Initializing presentation service');
const service = new PresentationService(editor);
setPresentationService(service);
service.startPresentationMode();
} else if (!newValue && presentationService) {
// Stopping presentation mode
logger.info('presentation', '🛑 Stopping presentation service');
presentationService.stopPresentationMode();
setPresentationService(null);
}
return newValue;
});
}, [presentationService]);
return (
<TLDrawContext.Provider
value={{
tldrawPreferences,
tldrawUserFilePath,
localSnapshot,
presentationMode,
sharedStore,
connectionStatus,
presentationService,
setTldrawPreferences,
setTldrawUserFilePath,
handleLocalSnapshot,
togglePresentationMode,
initializePreferences,
setSharedStore,
setConnectionStatus
}}
>
{children}
</TLDrawContext.Provider>
);
};
export const useTLDraw = () => useContext(TLDrawContext);
+191
View File
@@ -0,0 +1,191 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { supabase } from '../supabaseClient';
import { logger } from '../debugConfig';
import { CCUser, CCUserMetadata } from '../services/auth/authService';
import { UserPreferences } from '../services/auth/profileService';
import { DatabaseNameService } from '../services/graph/databaseNameService';
export interface UserContextType {
user: CCUser | null;
loading: boolean;
error: Error | null;
profile: CCUser | null;
preferences: UserPreferences;
isMobile: boolean;
isInitialized: boolean;
updateProfile: (updates: Partial<CCUser>) => Promise<void>;
updatePreferences: (updates: Partial<UserPreferences>) => Promise<void>;
clearError: () => void;
}
export const UserContext = createContext<UserContextType>({
user: null,
loading: true,
error: null,
profile: null,
preferences: {},
isMobile: false,
isInitialized: false,
updateProfile: async () => {},
updatePreferences: async () => {},
clearError: () => {}
});
export function UserProvider({ children }: { children: React.ReactNode }) {
const [user] = useState<CCUser | null>(null);
const [profile, setProfile] = useState<CCUser | null>(null);
const [preferences, setPreferences] = useState<UserPreferences>({});
const [loading, setLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isMobile] = useState(window.innerWidth <= 768);
useEffect(() => {
const loadUserProfile = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
setProfile(null);
setLoading(false);
setIsInitialized(true);
return;
}
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
if (error) {
throw error;
}
const metadata = user.user_metadata as CCUserMetadata;
const userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', metadata.username || '');
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
const userProfile: CCUser = {
id: user.id,
email: user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: userDbName,
school_db_name: schoolDbName,
created_at: user.created_at,
updated_at: user.updated_at
};
setProfile(userProfile);
logger.debug('user-context', '✅ User profile loaded', {
userId: userProfile.id,
userType: userProfile.user_type,
username: userProfile.username,
userDbName: userProfile.user_db_name,
schoolDbName: userProfile.school_db_name
});
// Load preferences from profile data
setPreferences({
theme: data.theme || 'system',
notifications: data.notifications_enabled || false
});
} catch (error) {
logger.error('user-context', '❌ Failed to load user profile', { error });
setError(error instanceof Error ? error : new Error('Failed to load user profile'));
} finally {
setLoading(false);
setIsInitialized(true);
}
};
loadUserProfile();
}, []);
const updateProfile = async (updates: Partial<CCUser>) => {
if (!user?.id || !profile) {
return;
}
setLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
...updates,
updated_at: new Date().toISOString()
})
.eq('id', user.id);
if (error) {
throw error;
}
setProfile(prev => prev ? { ...prev, ...updates } : null);
logger.info('user-context', '✅ Profile updated successfully');
} catch (error) {
logger.error('user-context', '❌ Failed to update profile', { error });
setError(error instanceof Error ? error : new Error('Failed to update profile'));
throw error;
} finally {
setLoading(false);
}
};
const updatePreferences = async (updates: Partial<UserPreferences>) => {
if (!user?.id) {
return;
}
setLoading(true);
try {
const newPreferences = { ...preferences, ...updates };
setPreferences(newPreferences);
const { error } = await supabase
.from('profiles')
.update({
preferences: newPreferences,
updated_at: new Date().toISOString()
})
.eq('id', user.id);
if (error) {
throw error;
}
logger.info('user-context', '✅ Preferences updated successfully');
} catch (error) {
logger.error('user-context', '❌ Failed to update preferences', { error });
setError(error instanceof Error ? error : new Error('Failed to update preferences'));
throw error;
} finally {
setLoading(false);
}
};
return (
<UserContext.Provider
value={{
user: profile,
loading,
error,
profile,
preferences,
isMobile,
isInitialized,
updateProfile,
updatePreferences,
clearError: () => setError(null)
}}
>
{children}
</UserContext.Provider>
);
}
export const useUser = () => useContext(UserContext);
+342
View File
@@ -0,0 +1,342 @@
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
export type LogCategory =
| 'app'
| 'header'
| 'not-found'
| 'routing'
| 'neo4j-service'
| 'site-page'
| 'supabase-client'
| 'user-page'
| 'auth-page'
| 'supabase-profile-service'
| 'email-signup-form'
| 'routes'
| 'super-admin-section'
| 'tldraw-context'
| 'user-context'
| 'super-admin-auth-route'
| 'admin-page'
| 'neo4j-context'
| 'auth-context'
| 'neo-user-context'
| 'neo-institute-context'
| 'auth-service'
| 'graph-service'
| 'registration-service'
| 'snapshot-service'
| 'shared-store-service'
| 'sync-service'
| 'state-management'
| 'local-store-service'
| 'storage-service'
| 'school-service'
| 'timetable-service'
| 'local-storage'
| 'single-player-page'
| 'multiplayer-page'
| 'login-page'
| 'signup-page'
| 'login-form'
| 'dev-page'
| 'axios'
| 'tldraw-events'
| 'user-toolbar'
| 'snapshot-toolbar'
| 'microphone-state-tool'
| 'graph-shape'
| 'graph-panel'
| 'graph-shape-shared' // For shared graph shape functionality
| 'graph-shape-user' // For user node specific functionality
| 'graph-shape-teacher' // For teacher node specific functionality
| 'graph-shape-student' // For student node specific functionality
| 'calendar-shape'
| 'calendar'
| 'supabase'
| 'binding'
| 'translation'
| 'position'
| 'array'
| 'shape'
| 'baseNodeShapeUtil'
| 'general'
| 'system'
| 'slides-panel'
| 'graphStateUtil'
| 'navigation' // For slide navigation
| 'presentation' // For presentation mode
| 'selection' // For slide/slideshow selection
| 'camera' // For camera movements
| 'tldraw-service' // For tldraw related logs
| 'store-service' // For store related logs
| 'morphic-page' // For Morphic page related logs
| 'share-handler' // For share handler related logs
| 'transcription-service' // For transcription service related logs
| 'slideshow-helpers' // For slideshow helpers related logs
| 'slide-shape' // For slide shape util related logs
| 'cc-base-shape-util' // For cc base shape util related logs
| 'cc-user-node-shape-util' // For cc user node shape util related logs
| 'node-canvas' // For node canvas related logs
| 'navigation-service' // For navigation service related logs
| 'autosave' // For autosave service related logs
| 'cc-exam-marker' // For cc exam marker related logs
| 'cc-search' // For cc search related logs
| 'cc-web-browser' // For cc web browser related logs
| 'cc-node-snapshot-panel' // For cc node snapshot related logs
| 'user-neo-db'
| 'navigation-queue-service' // For navigation queue service related logs
| 'editor-state' // For editor state related logs
| 'neo-shape-service' // For neo shape service related logs
// New navigation-specific categories
| 'navigation-context' // Context switching and state
| 'navigation-history' // History management
| 'navigation-ui' // UI interactions in navigation
| 'navigation-store' // Navigation store updates
| 'navigation-queue' // Navigation queue operations
| 'navigation-state' // Navigation state changes
| 'context-switch' // Context switching operations
| 'history-management' // History stack operations
| 'node-navigation' // Node-specific navigation
| 'navigation-panel' // Navigation panel related logs
| 'auth'
| 'school-context'
| 'database-name-service'
| 'tldraw'
| 'websocket'
| 'app'
| 'storage-service'
| 'routing'
| 'auth-service'
| 'user-context'
| 'neo-user-context'
| 'neo-institute-context';
interface LogConfig {
enabled: boolean; // Master switch to turn logging on/off
level: LogLevel; // Current log level
categories: LogCategory[]; // Which categories to show
}
const LOG_LEVELS: Record<LogLevel, number> = {
error: 0, // Always shown if enabled
warn: 1, // Shows warns and errors
info: 2, // Shows info, warns, and errors
debug: 3, // Shows debug and above
trace: 4, // Shows everything
};
class DebugLogger {
private config: LogConfig = {
enabled: true,
level: 'debug',
categories: [
'system',
'navigation',
'presentation',
'selection',
'camera',
'binding',
'shape',
'tldraw-service',
],
};
setConfig(config: Partial<LogConfig>) {
this.config = { ...this.config, ...config };
}
private shouldLog(level: LogLevel, category: LogCategory): boolean {
return (
this.config.enabled &&
LOG_LEVELS[level] <= LOG_LEVELS[this.config.level] &&
this.config.categories.includes(category)
);
}
log(level: LogLevel, category: LogCategory, message: string, data?: unknown) {
if (!this.shouldLog(level, category)) {
return;
}
const levelEmojis: Record<LogLevel, string> = {
error: '🔴', // Red circle for errors
warn: '⚠️', // Warning symbol
info: '️', // Information symbol
debug: '🔧', // Wrench for debug
trace: '🔍', // Magnifying glass for trace
};
const prefix = `${levelEmojis[level]} [${category}]`;
// Use appropriate console method based on level
switch (level) {
case 'error':
if (data) {
console.error(`${prefix} ${message}`, data);
} else {
console.error(`${prefix} ${message}`);
}
break;
case 'warn':
if (data) {
console.warn(`${prefix} ${message}`, data);
} else {
console.warn(`${prefix} ${message}`);
}
break;
case 'info':
if (data) {
console.info(`${prefix} ${message}`, data);
} else {
console.info(`${prefix} ${message}`);
}
break;
case 'debug':
if (data) {
console.debug(`${prefix} ${message}`, data);
} else {
console.debug(`${prefix} ${message}`);
}
break;
case 'trace':
if (data) {
console.trace(`${prefix} ${message}`, data);
} else {
console.trace(`${prefix} ${message}`);
}
break;
}
}
// Convenience methods
error(category: LogCategory, message: string, data?: unknown) {
this.log('error', category, message, data);
}
warn(category: LogCategory, message: string, data?: unknown) {
this.log('warn', category, message, data);
}
info(category: LogCategory, message: string, data?: unknown) {
this.log('info', category, message, data);
}
debug(category: LogCategory, message: string, data?: unknown) {
this.log('debug', category, message, data);
}
trace(category: LogCategory, message: string, data?: unknown) {
this.log('trace', category, message, data);
}
}
export const logger = new DebugLogger();
logger.setConfig({
enabled: true,
level: 'debug',
categories: [
'app',
'header',
'routing',
'neo4j-context',
'auth-context',
'auth-service',
'state-management',
'local-storage',
'axios',
'system',
'navigation',
'calendar',
'presentation',
'selection',
'camera',
'binding',
'shape',
'tldraw-service',
'tldraw-events',
'signup-page',
'timetable-service',
'dev-page',
'super-admin-auth-route',
'admin-page',
'storage-service',
'user-context',
'login-form',
'super-admin-section',
'routes',
'neo4j-service',
'supabase-client',
'user-page',
'site-page',
'auth-page',
'email-signup-form',
'supabase-profile-service',
'multiplayer-page',
'snapshot-service',
'sync-service',
'slides-panel',
'local-store-service',
'shared-store-service',
'single-player-page',
'user-toolbar',
'registration-service',
'graph-service',
'graph-shape',
'calendar-shape',
'snapshot-toolbar',
'graphStateUtil',
'baseNodeShapeUtil',
'school-service',
'microphone-state-tool',
'store-service',
'morphic-page',
'not-found',
'share-handler',
'transcription-service',
'slideshow-helpers',
'slide-shape',
'graph-panel',
'cc-user-node-shape-util',
'cc-base-shape-util',
'node-canvas',
'navigation-service',
'autosave',
'cc-exam-marker',
'cc-search',
'cc-web-browser',
'neo-user-context',
'neo-institute-context',
'cc-node-snapshot-panel',
'user-neo-db',
'navigation-queue-service',
'editor-state',
'neo-shape-service',
// Add new navigation categories
'navigation-context',
'navigation-history',
'navigation-ui',
'navigation-store',
'navigation-queue',
'navigation-state',
'context-switch',
'history-management',
'node-navigation',
'navigation-panel',
'auth',
'school-context',
'database-name-service',
'tldraw',
'websocket',
'app',
'auth-service',
'storage-service',
'routing',
'auth-service',
'user-context',
'neo-user-context',
'neo-institute-context',
],
});
export default logger;
+507
View File
@@ -0,0 +1,507 @@
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles */
html,
body {
padding: 0;
margin: 0;
overscroll-behavior: none;
touch-action: none;
min-height: 100vh;
/* mobile viewport bug fix */
min-height: -webkit-fill-available;
height: 100%;
}
/* Container styles */
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
/* Typography styles */
@media (max-width: 600px) {
.MuiTypography-h2 {
font-size: 2rem !important;
line-height: 2.5rem !important;
margin-bottom: 16px !important;
padding: 0 16px !important;
word-break: break-word !important;
}
.MuiTypography-h5 {
font-size: 1.25rem !important;
line-height: 1.75rem !important;
margin-bottom: 16px !important;
padding: 0 16px !important;
}
}
/* Form and input styles */
@media (max-width: 600px) {
.MuiContainer-root {
padding: 20px !important;
}
.MuiGrid-container {
gap: 16px !important;
padding: 0 16px !important;
justify-content: center !important;
}
.MuiGrid-item {
width: 100% !important;
max-width: 100% !important;
flex-basis: 100% !important;
padding: 0 !important;
}
.MuiTextField-root {
width: 100% !important;
margin: 0 !important;
}
.MuiButton-root {
width: 100% !important;
margin: 0 !important;
}
.login-form {
gap: 8px;
}
}
/* Add styles for wider screens */
@media (min-width: 601px) {
.MuiTextField-root {
width: 100% !important;
margin: 0 !important;
}
.MuiButton-root {
width: 100% !important;
margin: 0 !important;
}
}
/* Add this after your existing styles */
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.login-buttons-container {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
margin-top: 16px;
}
@media (max-width: 600px) {
.login-buttons-container {
gap: 8px;
}
}
.login-form-container {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 16px;
}
.login-form-container .MuiTextField-root,
.login-form-container .MuiButton-root {
width: 100%;
}
/* Adjust spacing for mobile */
@media (max-width: 600px) {
.login-form-container {
gap: 8px;
}
}
.login-section {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.login-section-header {
text-align: center;
color: #666;
font-size: 1rem;
font-weight: 500;
margin-top: 8px;
}
.login-role-header {
font-weight: 600 !important;
color: #1976d2 !important;
margin-bottom: 24px !important;
padding-bottom: 8px !important;
border-bottom: 2px solid #1976d2 !important;
width: fit-content !important;
}
@media (max-width: 600px) {
.login-role-header {
font-size: 1.75rem !important;
margin-bottom: 16px !important;
}
}
/* Calendar styles */
.fc-timegrid-slot {
height: 2em !important;
}
.fc-timegrid-event {
min-height: 2.5em !important;
}
.fc-timegrid-slot-label {
vertical-align: middle;
}
.fc-event {
cursor: pointer;
overflow: visible !important;
}
.fc-event:hover {
filter: brightness(90%);
}
.fc-event-title {
font-weight: bold;
}
.custom-event-content > div {
transition: all 0.3s ease;
overflow: hidden;
max-height: 1.5em;
}
.custom-event-content > div[style*="display: none"] {
max-height: 0;
opacity: 0;
}
/* Custom button styling */
.fc-filterClassesButton-button {
background-color: #4CAF50;
border: none;
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
border-radius: 4px;
}
/* Modal styling */
.class-filter-modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 9999;
max-width: 90%;
width: 400px;
padding-bottom: 50px;
}
.class-filter-modal h2 {
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
text-align: center;
}
.class-filter-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9998;
}
.class-filter-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.class-filter-button {
display: flex;
align-items: center;
padding: 10px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.class-filter-button:hover {
opacity: 0.8;
}
.class-filter-button .checkbox {
width: 20px;
height: 20px;
border-radius: 4px;
border: 2px solid currentColor;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
/* Add this to ensure the checkbox is visible */
background-color: rgba(255, 255, 255, 0.5);
}
.class-filter-button .checkbox svg {
font-size: 12px;
}
.class-filter-button span {
flex-grow: 1;
text-align: left;
}
.close-button {
background-color: #2C3E50;
border: 1px solid #2C3E50;
color: #fff;
padding: 6px 12px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 14px;
margin: 4px 2px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s ease, color 0.3s ease;
}
.close-button:hover {
background-color: #34495E;
border-color: #34495E;
}
.close-button-container {
text-align: center;
margin-top: 20px;
}
.event-details-modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 9999;
max-width: 90%;
width: 400px;
}
.event-details-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9998;
}
.open-tldraw-button {
background-color: #4CAF50;
border: none;
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-flex;
align-items: center;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.open-tldraw-button:hover {
background-color: #45a049;
}
.open-tldraw-button svg {
margin-left: 8px;
}
.event-dropdown {
position: absolute;
z-index: 1100; /* Higher value to ensure it appears above events */
right: -5px;
top: 25px;
background-color: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
min-width: 180px;
max-width: 250px;
min-height: 185px; /* Ensure a minimum height */
max-height: 200px;
padding: 5px;
overflow-y: scroll; /* Always show scrollbar */
display: flex;
flex-direction: column;
}
.event-dropdown div {
padding: 8px 12px;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
color: #000;
flex-shrink: 0;
}
.event-dropdown div:not(:last-child) {
border-bottom: 1px solid #eee; /* Add separators between items */
}
.event-dropdown div:hover {
background-color: #f0f0f0;
}
/* Styling for webkit browsers */
.event-dropdown::-webkit-scrollbar {
width: 8px;
}
.event-dropdown::-webkit-scrollbar-track {
background: #f1f1f1;
}
.event-dropdown::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.event-dropdown::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Styling for Firefox */
.event-dropdown {
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
}
/* Ensure the dropdown is on top of other elements */
.fc-event-main {
overflow: visible !important;
}
/* Style for the ellipsis icon */
.custom-event-content .fa-ellipsis-v {
opacity: 0.7;
transition: opacity 0.3s ease;
font-size: 16px;
}
.custom-event-content .fa-ellipsis-v:hover {
opacity: 1;
}
/* Add this new style to ensure the event content doesn't overflow */
.fc-event-main-frame {
overflow: visible !important;
}
/* View Toggle Modal styles */
.view-toggle-modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 9999;
max-width: 90%;
width: 300px;
}
.view-toggle-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9998;
}
.view-toggle-modal h2 {
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
text-align: center;
}
.view-toggle-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.view-toggle-button {
background-color: #4CAF50;
border: none;
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.view-toggle-button:hover {
background-color: #45a049;
}
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { initializeApp } from './services/initService';
import App from './App';
import './index.css';
const isDevMode = import.meta.env.VITE_DEV === 'true';
// Initialize the app before rendering
initializeApp();
// In development, React.StrictMode causes components to render twice
// This is intentional and helps catch certain bugs, but can be disabled
// if double-mounting is causing issues with initialization
const AppWithStrictMode = isDevMode ? (
<React.StrictMode>
<App />
</React.StrictMode>
) : (
<App />
);
// Register SW only if we're on the app subdomain
if ('serviceWorker' in navigator && window.location.hostname.startsWith('app.')) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(console.error);
});
}
ReactDOM.createRoot(document.getElementById('root')!).render(
AppWithStrictMode
);
BIN
View File
Binary file not shown.
+305
View File
@@ -0,0 +1,305 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
AppBar,
Toolbar,
Typography,
IconButton,
Box,
useTheme,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Divider
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import {
Login as LoginIcon,
Logout as LogoutIcon,
School as TeacherIcon,
Person as StudentIcon,
Dashboard as TLDrawDevIcon,
Build as DevToolsIcon,
Groups as MultiplayerIcon,
CalendarToday as CalendarIcon,
Assignment as TeacherPlannerIcon,
AssignmentTurnedIn as ExamMarkerIcon,
Settings as SettingsIcon,
Search as SearchIcon,
AdminPanelSettings as AdminIcon
} from '@mui/icons-material';
import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator';
const Header: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { user, signOut } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
const showGraphNavigation = location.pathname === '/single-player';
// Update authentication state whenever user changes
useEffect(() => {
const newAuthState = !!user;
setIsAuthenticated(newAuthState);
logger.debug('user-context', '🔄 User state changed in header', {
hasUser: newAuthState,
userId: user?.id,
userEmail: user?.email,
userState: newAuthState ? 'logged-in' : 'logged-out',
isAdmin
});
}, [user, isAdmin]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleNavigation = (path: string) => {
navigate(path);
handleMenuClose();
};
const handleSignupNavigation = (role: 'teacher' | 'student') => {
navigate('/signup', { state: { role } });
handleMenuClose();
};
const handleSignOut = async () => {
try {
logger.debug('auth-service', '🔄 Signing out user', { userId: user?.id });
await signOut();
// Clear local state immediately
setIsAuthenticated(false);
setAnchorEl(null);
logger.debug('auth-service', '✅ User signed out');
} catch (error) {
logger.error('auth-service', '❌ Error signing out:', error);
console.error('Error signing out:', error);
}
};
return (
<AppBar
position="fixed"
sx={{
height: `${HEADER_HEIGHT}px`,
bgcolor: theme.palette.background.paper,
color: theme.palette.text.primary,
boxShadow: 1
}}
>
<Toolbar sx={{
display: 'flex',
justifyContent: 'space-between',
minHeight: `${HEADER_HEIGHT}px !important`,
height: `${HEADER_HEIGHT}px`,
gap: 2,
px: { xs: 1, sm: 2 }
}}>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
minWidth: { xs: 'auto', sm: '200px' }
}}>
<Typography
variant="h6"
component="div"
className="app-title"
sx={{
cursor: 'pointer',
color: theme.palette.text.primary,
'&:hover': {
color: theme.palette.primary.main
},
fontSize: { xs: '1rem', sm: '1.25rem' }
}}
onClick={() => navigate(isAuthenticated ? '/single-player' : '/')}
>
ClassroomCopilot
</Typography>
</Box>
<Box sx={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
justifyContent: 'center',
visibility: showGraphNavigation ? 'visible' : 'hidden',
width: {
xs: 'calc(100% - 160px)', // More space for menu and title
sm: 'calc(100% - 200px)', // Standard spacing
md: 'auto' // Full width on medium and up
},
maxWidth: '800px',
'& .navigation-controls': {
display: { xs: 'none', sm: 'flex' }
},
'& .context-section': {
display: { xs: 'none', md: 'flex' }
},
'& .context-toggle': {
display: 'flex' // Always show the profile/institute toggle
}
}}>
<GraphNavigator />
</Box>
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
minWidth: { xs: 'auto', sm: '200px' },
ml: 'auto'
}}>
<IconButton
className="menu-button"
color="inherit"
onClick={handleMenuOpen}
edge="end"
sx={{
'&:hover': {
bgcolor: theme.palette.action.hover
}
}}
>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
slotProps={{
paper: {
elevation: 3,
sx: {
bgcolor: theme.palette.background.paper,
color: theme.palette.text.primary,
minWidth: '240px'
}
}
}}
>
{isAuthenticated ? [
// Development Tools Section
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
<ListItemIcon>
<TLDrawDevIcon />
</ListItemIcon>
<ListItemText primary="TLDraw Dev" />
</MenuItem>,
<MenuItem key="dev" onClick={() => handleNavigation('/dev')}>
<ListItemIcon>
<DevToolsIcon />
</ListItemIcon>
<ListItemText primary="Dev Tools" />
</MenuItem>,
<Divider key="dev-divider" />,
// Main Features Section
<MenuItem key="multiplayer" onClick={() => handleNavigation('/multiplayer')}>
<ListItemIcon>
<MultiplayerIcon />
</ListItemIcon>
<ListItemText primary="Multiplayer" />
</MenuItem>,
<MenuItem key="calendar" onClick={() => handleNavigation('/calendar')}>
<ListItemIcon>
<CalendarIcon />
</ListItemIcon>
<ListItemText primary="Calendar" />
</MenuItem>,
<MenuItem key="planner" onClick={() => handleNavigation('/teacher-planner')}>
<ListItemIcon>
<TeacherPlannerIcon />
</ListItemIcon>
<ListItemText primary="Teacher Planner" />
</MenuItem>,
<MenuItem key="exam" onClick={() => handleNavigation('/exam-marker')}>
<ListItemIcon>
<ExamMarkerIcon />
</ListItemIcon>
<ListItemText primary="Exam Marker" />
</MenuItem>,
<Divider key="features-divider" />,
// Utilities Section
<MenuItem key="settings" onClick={() => handleNavigation('/settings')}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</MenuItem>,
<MenuItem key="search" onClick={() => handleNavigation('/search')}>
<ListItemIcon>
<SearchIcon />
</ListItemIcon>
<ListItemText primary="Search" />
</MenuItem>,
// Admin Section
...(isAdmin ? [
<Divider key="admin-divider" />,
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
<ListItemIcon>
<AdminIcon />
</ListItemIcon>
<ListItemText primary="Admin Dashboard" />
</MenuItem>
] : []),
// Authentication Section
<Divider key="auth-divider" />,
<MenuItem key="signout" onClick={handleSignOut}>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="Sign Out" />
</MenuItem>
] : [
// Authentication Section for Non-authenticated Users
<MenuItem key="signin" onClick={() => handleNavigation('/login')}>
<ListItemIcon>
<LoginIcon />
</ListItemIcon>
<ListItemText primary="Sign In" />
</MenuItem>,
<Divider key="signup-divider" />,
<MenuItem key="teacher-signup" onClick={() => handleSignupNavigation('teacher')}>
<ListItemIcon>
<TeacherIcon />
</ListItemIcon>
<ListItemText
primary="Sign up as Teacher"
secondary="Create a teacher account"
/>
</MenuItem>,
<MenuItem key="student-signup" onClick={() => handleSignupNavigation('student')}>
<ListItemIcon>
<StudentIcon />
</ListItemIcon>
<ListItemText
primary="Sign up as Student"
secondary="Create a student account"
/>
</MenuItem>
]}
</Menu>
</Box>
</Toolbar>
</AppBar>
);
};
export default Header;
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import Header from './Header';
interface LayoutProps {
children: React.ReactNode;
}
export const HEADER_HEIGHT = 40; // in pixels
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div>
<Header />
<main className="main-content" style={{
paddingTop: `${HEADER_HEIGHT}px`,
height: '100vh',
width: '100%'
}}>
{children}
</main>
</div>
);
};
export default Layout;
+51
View File
@@ -0,0 +1,51 @@
import { useNavigate } from "react-router-dom";
import { Box, Typography, Button, Container, useTheme } from "@mui/material";
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { logger } from '../debugConfig';
function NotFoundPublic() {
const theme = useTheme();
const navigate = useNavigate();
const handleReturn = () => {
logger.debug('not-found', '🔄 Public user navigating to home');
navigate('/');
};
return (
<Container maxWidth="sm">
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
textAlign: 'center',
gap: 3
}}
>
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
<Typography variant="h2" component="h1" gutterBottom>
404
</Typography>
<Typography variant="h5" gutterBottom>
Page Not Found
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
The page you're looking for doesn't exist or has been moved.
</Typography>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleReturn}
>
Return to Canvas
</Button>
</Box>
</Container>
);
}
export default NotFoundPublic;
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export default function AuthCallback() {
const navigate = useNavigate();
useEffect(() => {
// Redirect to login page since we're no longer supporting external authentication
navigate('/login');
}, [navigate]);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-6 shadow-lg">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Redirecting...</h2>
<p className="mt-2 text-sm text-gray-600">Please wait while we redirect you to the login page...</p>
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Alert } from '@mui/material';
import { EmailCredentials } from '../../services/auth/authService';
interface EmailLoginFormProps {
role: 'email_teacher' | 'email_student';
onSubmit: (credentials: EmailCredentials) => Promise<void>;
}
export const EmailLoginForm: React.FC<EmailLoginFormProps> = ({ role, onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
await onSubmit({ email, password, role });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to login');
} finally {
setIsLoading(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
margin="normal"
required
/>
<TextField
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
disabled={isLoading}
sx={{ mt: 3 }}
>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</Box>
);
};
+139
View File
@@ -0,0 +1,139 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Alert, Stack } from '@mui/material';
import { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
interface EmailSignupFormProps {
role: 'email_teacher' | 'email_student';
onSubmit: (
credentials: EmailCredentials,
displayName: string
) => Promise<void>;
}
export const EmailSignupForm: React.FC<EmailSignupFormProps> = ({
role,
onSubmit,
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const validateForm = () => {
if (!email || !password || !confirmPassword || !displayName) {
return 'All fields are required';
}
if (password !== confirmPassword) {
return 'Passwords do not match';
}
if (password.length < 6) {
return 'Password must be at least 6 characters';
}
if (!email.includes('@')) {
return 'Please enter a valid email address';
}
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
setError(null);
setIsLoading(true);
try {
logger.debug('email-signup-form', '🔄 Submitting signup form', {
email,
role,
hasDisplayName: !!displayName,
});
await onSubmit({ email, password, role }, displayName);
} catch (err) {
setError(
err instanceof Error ? err.message : 'An error occurred during signup'
);
logger.error(
'email-signup-form',
'❌ Signup form submission failed',
err
);
} finally {
setIsLoading(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<Stack spacing={2}>
<TextField
required
fullWidth
id="displayName"
label="Display Name"
name="displayName"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoFocus
/>
<TextField
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<TextField
required
fullWidth
name="confirmPassword"
label="Confirm Password"
type="password"
id="confirmPassword"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{error && <Alert severity="error">{error}</Alert>}
<Button
type="submit"
fullWidth
variant="contained"
disabled={
isLoading || !email || !password || !confirmPassword || !displayName
}
>
{isLoading ? 'Signing up...' : 'Sign Up'}
</Button>
</Stack>
</Box>
);
};
+113
View File
@@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { Container, Box, Typography, Tabs, Tab, Paper, Button } from '@mui/material';
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
import { SchoolUploadSection } from '../components/admin/SchoolUploadSection';
import { TimetableUploadSection } from '../components/admin/TimetableUploadSection';
import { logger } from '../../debugConfig';
const SUPER_ADMIN_EMAIL = import.meta.env.VITE_SUPER_ADMIN_EMAIL;
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div hidden={value !== index} {...other}>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
export default function AdminDashboard() {
const { user } = useAuth();
const navigate = useNavigate();
const [tabValue, setTabValue] = useState(0);
const isSuperAdmin = user?.email === SUPER_ADMIN_EMAIL;
logger.debug('admin-page', '🔍 Super admin check', {
userEmail: user?.email,
superAdminEmail: SUPER_ADMIN_EMAIL,
isMatch: isSuperAdmin
});
const handleReturn = () => {
logger.info('admin-page', '🏠 Returning to single player page');
navigate('/single-player');
};
if (!isSuperAdmin) {
logger.error('admin-page', '🚫 Unauthorized access attempt', {
userEmail: user?.email,
requiredEmail: SUPER_ADMIN_EMAIL
});
return (
<Container>
<Typography variant="h4" color="error">Unauthorized Access</Typography>
<Button
onClick={handleReturn}
variant="contained"
sx={{ mt: 2 }}
>
Return to User Page
</Button>
</Container>
);
}
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
return (
<Container maxWidth="lg" sx={{ mt: 4 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2
}}>
<Typography variant="h4">Admin Dashboard</Typography>
<Button
onClick={handleReturn}
variant="outlined"
>
Return to User Page
</Button>
</Box>
<Paper sx={{ width: '100%', mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="System Settings" />
<Tab label="Database Management" />
<Tab label="User Management" />
<Tab label="Schools" />
<Tab label="Timetables" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<Typography variant="h6">System Settings</Typography>
{/* Add system settings components here */}
</TabPanel>
<TabPanel value={tabValue} index={1}>
<Typography variant="h6">Database Management</Typography>
{/* Add database management components here */}
</TabPanel>
<TabPanel value={tabValue} index={2}>
<Typography variant="h6">User Management</Typography>
{/* Add user management components here */}
</TabPanel>
<TabPanel value={tabValue} index={3}>
<SchoolUploadSection />
</TabPanel>
<TabPanel value={tabValue} index={4}>
<TimetableUploadSection />
</TabPanel>
</Paper>
</Container>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Container, Typography, Box, Alert } from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { EmailLoginForm } from './EmailLoginForm';
import { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { user, signIn } = useAuth();
const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user
});
useEffect(() => {
if (user) {
navigate('/single-player');
}
}, [user, navigate]);
const handleLogin = async (credentials: EmailCredentials) => {
try {
setError(null);
await signIn(credentials.email, credentials.password);
navigate('/single-player');
} catch (error) {
logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed');
throw error;
}
};
if (user) {
return null;
}
return (
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 4
}}
>
<Typography variant="h2" component="h1" gutterBottom>
ClassroomCopilot.ai
</Typography>
<Typography variant="h4" gutterBottom>
Login
</Typography>
{error && (
<Alert severity="error" sx={{ width: '100%', maxWidth: 400 }}>
{error}
</Alert>
)}
<Box sx={{ width: '100%', maxWidth: 400 }}>
<EmailLoginForm
role="email_teacher"
onSubmit={handleLogin}
/>
</Box>
</Container>
);
};
export default LoginPage;
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Container,
Typography,
Box,
Button,
Stack,
Divider,
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { EmailSignupForm } from './EmailSignupForm';
import { EmailCredentials } from '../../services/auth/authService';
import { RegistrationService } from '../../services/auth/registrationService';
import { logger } from '../../debugConfig';
import MicrosoftIcon from '@mui/icons-material/Microsoft';
const SignupPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { user } = useAuth();
const registrationService = RegistrationService.getInstance();
// Get role from location state, default to teacher
const { role = 'teacher' } = location.state || {};
const roleDisplay = role === 'teacher' ? 'Teacher' : 'Student';
logger.debug('signup-page', '🔍 Signup page loaded', {
role,
hasUser: !!user,
});
useEffect(() => {
if (user) {
navigate('/single-player');
}
}, [user, navigate]);
const handleSignup = async (
credentials: EmailCredentials,
displayName: string
) => {
try {
const result = await registrationService.register(
credentials,
displayName
);
if (result.user) {
navigate('/single-player');
}
} catch (error) {
logger.error('signup-page', '❌ Registration failed', error);
throw error;
}
};
const switchRole = () => {
navigate('/signup', {
state: { role: role === 'teacher' ? 'student' : 'teacher' },
});
};
if (user) {
return null;
}
return (
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 4,
}}
>
<Typography variant="h2" component="h1" gutterBottom>
ClassroomCopilot.ai
</Typography>
<Typography variant="h4" gutterBottom>
{roleDisplay} Sign Up
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<Button
fullWidth
variant="outlined"
startIcon={<MicrosoftIcon />}
onClick={() => {}}
sx={{ mb: 3 }}
>
Sign up with Microsoft
</Button>
<Divider sx={{ my: 2 }}>OR</Divider>
<EmailSignupForm
role={`email_${role}` as 'email_teacher' | 'email_student'}
onSubmit={handleSignup}
/>
<Stack
direction="row"
spacing={2}
justifyContent="center"
sx={{ mt: 3 }}
>
<Button variant="text" onClick={switchRole}>
Switch to {role === 'teacher' ? 'Student' : 'Teacher'} Sign Up
</Button>
</Stack>
</Box>
</Container>
);
};
export default SignupPage;
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { Button, Box, Typography, Alert } from '@mui/material';
import { logger } from '../../../debugConfig';
import { SchoolNeoDBService } from '../../../services/graph/schoolNeoDBService';
export const SchoolUploadSection = () => {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const handleSchoolUpload = async () => {
try {
setIsCreating(true);
setError(null);
setSuccess(null);
const result = await SchoolNeoDBService.createSchools();
setSuccess(result.message);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to upload school';
logger.error('admin-page', '❌ School upload failed:', error);
setError(errorMessage);
} finally {
setIsCreating(false);
}
};
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Create Schools
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
{success}
</Alert>
)}
<Button
variant="contained"
onClick={handleSchoolUpload}
disabled={isCreating}
>
{isCreating ? 'Creating...' : 'Create Schools'}
</Button>
</Box>
);
};
@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { Button, Box, Typography, Alert } from '@mui/material';
import { useNeoUser } from '../../../contexts/NeoUserContext';
import { TimetableNeoDBService } from '../../../services/graph/timetableNeoDBService';
import { CCTeacherNodeProps } from '../../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
export const TimetableUploadSection = () => {
const { userNode, workerNode } = useNeoUser();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleTimetableUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
setIsUploading(true);
setError(null);
setSuccess(null);
const result = await TimetableNeoDBService.handleTimetableUpload(
event.target.files?.[0],
userNode || undefined,
workerNode?.nodeData as CCTeacherNodeProps | undefined
);
if (result.success) {
setSuccess(result.message);
} else {
setError(result.message);
}
} finally {
setIsUploading(false);
if (event.target) {
event.target.value = '';
}
}
};
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Upload Teacher Timetable
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
{success}
</Alert>
)}
<Button
variant="contained"
component="label"
disabled={isUploading || !workerNode}
>
{isUploading ? 'Uploading...' : 'Upload Timetable'}
<input
type="file"
hidden
accept=".xlsx"
onChange={handleTimetableUpload}
disabled={isUploading}
/>
</Button>
{!workerNode && (
<Typography color="error" sx={{ mt: 1 }}>
No teacher node found. Please ensure you have the correct permissions.
</Typography>
)}
</Box>
);
};
+45
View File
@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import { Button, TextField } from '@mui/material';
import { EmailCredentials } from '../../../services/auth/authService';
import { logger } from '../../../debugConfig';
interface LoginFormProps {
role: 'email_teacher' | 'email_student';
onSubmit: (credentials: EmailCredentials) => Promise<void>;
}
export const LoginForm: React.FC<LoginFormProps> = ({ role, onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
logger.debug('login-form', '🔄 Submitting login form', { role });
await onSubmit({ email, password, role });
};
return (
<form onSubmit={handleSubmit} className="login-form">
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
fullWidth
autoComplete="new-username"
/>
<TextField
label="Password"
type="password"
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
autoComplete="new-password"
/>
<Button type="submit" variant="contained" fullWidth>
Login
</Button>
</form>
);
};
@@ -0,0 +1,23 @@
import { Button } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../../debugConfig';
export const SuperAdminSection = () => {
const navigate = useNavigate();
const handleAdminClick = () => {
logger.info('super-admin-section', '🔑 Navigating to admin page');
navigate('/admin');
};
return (
<Button
onClick={handleAdminClick}
variant="contained"
color="warning"
sx={{ mb: 2 }}
>
Admin Dashboard
</Button>
);
};
@@ -0,0 +1,12 @@
import { CircularProgress, Box } from '@mui/material';
export const LoadingSpinner = () => (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="100vh"
>
<CircularProgress />
</Box>
);
+28
View File
@@ -0,0 +1,28 @@
import React, { useEffect } from 'react';
import { Container, Typography, CircularProgress } from '@mui/material';
import { logger } from '../debugConfig';
const MorphicPage: React.FC = () => {
useEffect(() => {
// Redirect to the nginx-handled /morphic URL
window.location.href = '/morphic';
logger.debug('morphic-page', '🔄 Redirecting to Morphic');
}, []);
return (
<Container sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh'
}}>
<Typography variant="h5" gutterBottom>
Redirecting to Morphic...
</Typography>
<CircularProgress />
</Container>
);
};
export default MorphicPage;
+67
View File
@@ -0,0 +1,67 @@
import { useCallback } from 'react';
import {
ReactFlow,
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
Node,
Edge,
Connection,
addEdge,
BackgroundVariant
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const initialNodes: Node[] = [
{
id: '1',
type: 'input',
data: { label: 'Teacher Node' },
position: { x: 250, y: 25 },
},
{
id: '2',
data: { label: 'Class Node' },
position: { x: 100, y: 125 },
},
{
id: '3',
type: 'output',
data: { label: 'Student Node' },
position: { x: 400, y: 125 },
},
];
const initialEdges: Edge[] = [
{ id: 'e1-2', source: '1', target: '2' },
{ id: 'e1-3', source: '1', target: '3' },
];
export default function TeacherPlanner() {
const [nodes, , onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges],
);
return (
<div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Controls />
<MiniMap />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
</ReactFlow>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { Box } from '@mui/material';
import { HEADER_HEIGHT } from './Layout';
const SearxngPage: React.FC = () => {
return (
<Box sx={{
position: 'absolute',
top: HEADER_HEIGHT,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
bgcolor: 'background.default'
}}>
<iframe
src={`${import.meta.env.VITE_FRONTEND_SITE_URL}/searxng-api/`}
style={{
width: '100%',
height: '100%',
border: 'none',
display: 'block'
}}
title="SearXNG Search"
/>
</Box>
);
};
export default SearxngPage;
@@ -0,0 +1,92 @@
import { TLShapeId } from '@tldraw/tldraw';
export interface AnnotationData {
studentIndex?: number; // undefined for exam/markscheme annotations
pageIndex: number;
shapeId: TLShapeId;
bounds: {
x: number;
y: number;
width: number;
height: number;
};
}
export class AnnotationManager {
private examAnnotations: Set<TLShapeId> = new Set();
private markSchemeAnnotations: Set<TLShapeId> = new Set();
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
this.annotationData.set(shapeId, data);
if (data.studentIndex !== undefined) {
// Student response annotation
let studentSet = this.studentAnnotations.get(data.studentIndex);
if (!studentSet) {
studentSet = new Set();
this.studentAnnotations.set(data.studentIndex, studentSet);
}
studentSet.add(shapeId);
} else {
// Exam or mark scheme annotation
if (data.pageIndex < 0) {
this.examAnnotations.add(shapeId);
} else {
this.markSchemeAnnotations.add(shapeId);
}
}
}
removeAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (!data) return;
if (data.studentIndex !== undefined) {
const studentSet = this.studentAnnotations.get(data.studentIndex);
studentSet?.delete(shapeId);
} else {
if (data.pageIndex < 0) {
this.examAnnotations.delete(shapeId);
} else {
this.markSchemeAnnotations.delete(shapeId);
}
}
this.annotationData.delete(shapeId);
}
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
return Array.from(this.studentAnnotations.get(studentIndex) || []);
}
getAnnotationsForExam(): TLShapeId[] {
return Array.from(this.examAnnotations);
}
getAnnotationsForMarkScheme(): TLShapeId[] {
return Array.from(this.markSchemeAnnotations);
}
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
return this.annotationData.get(shapeId);
}
clear() {
this.examAnnotations.clear();
this.markSchemeAnnotations.clear();
this.studentAnnotations.clear();
this.annotationData.clear();
}
// Future transcription support
addTranscriptionToAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (data) {
this.annotationData.set(shapeId, {
...data
});
}
}
}
@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { Box } from '@mui/material';
import 'tldraw/tldraw.css';
import { CCPdfEditor } from './CCPdfEditor';
import { CCPdfPicker } from './CCPdfPicker';
import { ExamPdfState } from './types';
import './cc-exam-marker.css';
import { HEADER_HEIGHT } from '../../Layout';
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
export const CCExamMarker = () => {
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
const [isExpanded, setIsExpanded] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
setView(newView);
};
const handleNextStudent = () => {
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
if (currentStudentIndex < totalStudents - 1) {
setCurrentStudentIndex(prev => prev + 1);
}
}
};
const handlePreviousStudent = () => {
if (currentStudentIndex > 0) {
setCurrentStudentIndex(prev => prev - 1);
}
};
return (
<Box sx={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
color: 'text.primary',
}}>
{state.phase === 'pick' ? (
<CCPdfPicker
onOpenPdfs={(pdfs) =>
setState({
phase: 'edit',
examPaper: pdfs.examPaper,
markScheme: pdfs.markScheme,
studentResponses: pdfs.studentResponses,
})
}
/>
) : (
<Box sx={{ flex: 1, position: 'relative' }}>
<Box sx={{
position: 'absolute',
inset: 0,
bgcolor: 'background.paper',
}}>
<CCPdfEditor
examPaper={state.examPaper}
markScheme={state.markScheme}
studentResponses={state.studentResponses}
currentView={view}
currentStudentIndex={currentStudentIndex}
onEditorMount={(editor) => {
if (!editor) return null;
const examMarkerProps = {
editor,
currentView: view,
onViewChange: handleViewChange,
currentStudentIndex,
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
onPreviousStudent: handlePreviousStudent,
onNextStudent: handleNextStudent,
getCurrentPdf: () => {
if (!editor) return null;
const currentPageId = editor.getCurrentPageId();
if (currentPageId.includes('exam-page')) {
return state.examPaper;
} else if (currentPageId.includes('mark-scheme-page')) {
return state.markScheme;
} else if (currentPageId.includes('student-response')) {
return state.studentResponses;
}
return null;
},
};
return <CCPanel
examMarkerProps={examMarkerProps}
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={setIsExpanded}
onPinnedChange={setIsPinned}
/>;
}}
/>
</Box>
</Box>
)}
</Box>
);
};
@@ -0,0 +1,113 @@
import { PDFDocument } from 'pdf-lib';
import { useState } from 'react';
import { Editor, exportToBlob } from '@tldraw/tldraw';
import { Button } from '@mui/material';
import { Pdf } from './types';
interface CCExportPdfButtonProps {
editor: Editor;
pdf: Pdf;
}
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
const [exportProgress, setExportProgress] = useState<number | null>(null);
const exportPdf = async (
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void
) => {
const totalThings = pages.length * 2 + 2;
let progressCount = 0;
const tickProgress = () => {
progressCount++;
onProgress(progressCount / totalThings);
};
const pdf = await PDFDocument.load(source);
tickProgress();
const pdfPages = pdf.getPages();
if (pdfPages.length !== pages.length) {
throw new Error('PDF page count mismatch');
}
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
(id) => !pageShapeIds.has(id)
);
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const pdfPage = pdfPages[i];
const {bounds} = page;
const shapesInBounds = allIds.filter((id) => {
const shapePageBounds = editor.getShapePageBounds(id);
if (!shapePageBounds) return false;
return shapePageBounds.collides(bounds);
});
if (shapesInBounds.length === 0) {
tickProgress();
tickProgress();
continue;
}
const exportedPng = await exportToBlob({
editor,
ids: allIds,
format: 'png',
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
});
tickProgress();
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
});
tickProgress();
}
const url = URL.createObjectURL(
new Blob([await pdf.save()], { type: 'application/pdf' })
);
tickProgress();
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
};
return (
<Button
className="CCExportPdfButton"
variant="contained"
color="primary"
onClick={async () => {
setExportProgress(0);
try {
await exportPdf(editor, pdf, setExportProgress);
} finally {
setExportProgress(null);
}
}}
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 1000,
}}
>
{exportProgress
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export PDF'}
</Button>
);
}
@@ -0,0 +1,385 @@
import React from 'react';
import { Box } from '@mui/material';
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
import { Tldraw } from '@tldraw/tldraw';
import { useCallback, useEffect, useState, useRef } from 'react';
import { ExamPdfs } from './types';
import { AnnotationManager, AnnotationData } from './AnnotationManager';
import { logger } from '../../../debugConfig';
const PAGE_SPACING = 32; // Same spacing as the example
interface CCPdfEditorProps extends ExamPdfs {
currentView: 'exam-and-markscheme' | 'student-responses';
currentStudentIndex: number;
onEditorMount: (editor: Editor) => React.ReactNode;
}
export function CCPdfEditor({
examPaper,
markScheme,
studentResponses,
currentView,
currentStudentIndex,
onEditorMount,
}: CCPdfEditorProps) {
const [editor, setEditor] = useState<Editor | null>(null);
const [pagesInitialized, setPagesInitialized] = useState(false);
const annotationManager = useRef(new AnnotationManager());
const handleMount = useCallback((editor: Editor) => {
setEditor(editor);
onEditorMount(editor);
// Subscribe to shape changes
editor.on('change', () => {
const shapes = editor.getCurrentPageShapeIds();
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
totalShapes: shapes.size,
currentPage: editor.getCurrentPageId()
});
shapes.forEach(shapeId => {
const shape = editor.getShape(shapeId);
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
const bounds = editor.getShapePageBounds(shapeId);
if (bounds) {
const currentPageId = editor.getCurrentPageId();
let annotationData: AnnotationData;
if (currentPageId.includes('student-response')) {
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
// Find which page this annotation belongs to by checking collision with page bounds
const pageShapes = Array.from(shapes).filter(id => {
const s = editor.getShape(id);
return s?.isLocked; // Locked shapes are our PDF pages
});
let pageIndex = -1; // Default to -1 if no collision found
for (let i = 0; i < pageShapes.length; i++) {
const pageShape = editor.getShape(pageShapes[i]);
if (!pageShape) continue;
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
if (!pageBounds) continue;
// Check if the annotation's center point is within the page bounds
const annotationCenter = {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
};
if (annotationCenter.x >= pageBounds.x &&
annotationCenter.x <= pageBounds.x + pageBounds.width &&
annotationCenter.y >= pageBounds.y &&
annotationCenter.y <= pageBounds.y + pageBounds.height) {
pageIndex = i;
break;
}
}
logger.debug('cc-exam-marker', '📏 Calculated page index', {
shapeId,
shapeBounds: bounds,
pageIndex,
studentIndex
});
annotationData = {
studentIndex,
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
} else {
// For exam/mark scheme, use current page type as index
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
annotationData = {
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
}
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
shapeId,
annotationData,
currentPage: currentPageId
});
annotationManager.current.addAnnotation(shapeId, annotationData);
}
}
});
});
}, [onEditorMount]);
// Initial setup effect - runs only once when editor is mounted
useEffect(() => {
if (!editor || pagesInitialized) return;
const setupExamAndMarkScheme = async () => {
const examPageId = 'page:exam-page' as TLPageId;
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
// Calculate vertical layout for exam pages
let top = 0;
let widest = 0;
const examPages = examPaper.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
examPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create exam paper page
editor.createPage({
id: examPageId,
name: 'Exam Paper',
});
editor.setCurrentPage(examPageId);
// Create assets and shapes for exam pages
examPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Similar process for mark scheme pages
let markSchemeTop = 0;
const markSchemePages = markScheme.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = markSchemeTop;
markSchemeTop += height + PAGE_SPACING;
return {
...page,
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
};
});
// Create mark scheme page
editor.createPage({
id: markSchemePageId,
name: 'Mark Scheme',
});
editor.setCurrentPage(markSchemePageId);
// Create assets and shapes for mark scheme pages
markSchemePages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Go back to exam page
editor.setCurrentPage(examPageId);
};
const setupStudentResponses = async () => {
const pagesPerStudent = examPaper.pages.length;
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
// Calculate vertical layout
let top = 0;
let widest = 0;
const studentPages = studentResponses.pages
.slice(startPage, endPage)
.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
studentPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create page for this student
editor.createPage({
id: studentPageId,
name: `Student ${studentIndex + 1}`,
});
editor.setCurrentPage(studentPageId);
// Create assets and shapes
studentPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
}
};
// Initial setup of all pages
const setup = async () => {
await setupExamAndMarkScheme();
await setupStudentResponses();
setPagesInitialized(true);
};
setup();
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
// Effect to handle view changes and navigation
useEffect(() => {
if (!editor || !pagesInitialized) return;
// Switch to appropriate page based on current view
const targetPageId = currentView === 'exam-and-markscheme'
? ('page:exam-page' as TLPageId)
: (`page:student-response-${currentStudentIndex}` as TLPageId);
logger.debug('cc-exam-marker', '🔄 Switching view', {
currentView,
currentStudentIndex,
targetPageId
});
editor.setCurrentPage(targetPageId);
// Update camera constraints for current page
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
(acc: TLBox | null, shapeId) => {
const bounds = editor.getShapePageBounds(shapeId);
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
},
null as TLBox | null
);
if (currentPageBounds) {
const isMobile = editor.getViewportScreenBounds().width < 840;
editor.setCameraOptions({
constraints: {
bounds: currentPageBounds,
padding: { x: isMobile ? 16 : 164, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
});
editor.setCamera(editor.getCamera(), { reset: true });
}
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
// Expose annotationManager to parent through onEditorMount
useEffect(() => {
if (editor) {
onEditorMount(editor);
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
editor.annotationManager = annotationManager.current;
}
}, [editor, onEditorMount]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<Tldraw
onMount={handleMount}
components={{
InFrontOfTheCanvas: () => onEditorMount(editor!)
}}
/>
</Box>
);
}
@@ -0,0 +1,210 @@
import { useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
import { ExamPdfs, Pdf, PdfPage } from './types';
interface CCPdfPickerProps {
onOpenPdfs: (pdfs: ExamPdfs) => void;
}
const pageSpacing = 32;
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
const [isLoading, setIsLoading] = useState(false);
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
const PdfJS = await import('pdfjs-dist');
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
const pdf = await PdfJS.getDocument(source.slice()).promise;
const pages: PdfPage[] = [];
const canvas = window.document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Failed to create canvas context');
const visualScale = 1.5;
const scale = window.devicePixelRatio;
let top = 0;
let widest = 0;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: scale * visualScale });
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderContext = {
canvasContext: context,
viewport,
};
await page.render(renderContext).promise;
const width = viewport.width / scale;
const height = viewport.height / scale;
pages.push({
src: canvas.toDataURL(),
bounds: new TLBox(0, top, width, height),
assetId: AssetRecordType.createId(),
shapeId: createShapeId(),
});
top += height + pageSpacing;
widest = Math.max(widest, width);
}
canvas.width = 0;
canvas.height = 0;
for (const page of pages) {
page.bounds.x = (widest - page.bounds.width) / 2;
}
return {
name,
pages,
source,
};
}
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
setIsLoading(true);
try {
const pdf = await loadPdf(file.name, await file.arrayBuffer());
// Validate student responses page count
if (type === 'studentResponses' && selectedPdfs.examPaper) {
const examPageCount = selectedPdfs.examPaper.pages.length;
if (pdf.pages.length % examPageCount !== 0) {
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
return;
}
}
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
} catch (error) {
console.error('Error loading PDF:', error);
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
} finally {
setIsLoading(false);
}
};
const createFileInput = (type: keyof ExamPdfs) => {
const input = window.document.createElement('input');
input.type = 'file';
input.accept = 'application/pdf';
input.addEventListener('change', async (e) => {
const fileList = (e.target as HTMLInputElement).files;
if (!fileList || fileList.length === 0) return;
await handleFileSelect(type, fileList[0]);
});
input.click();
};
const allPdfsSelected = () => {
return (
selectedPdfs.examPaper &&
selectedPdfs.markScheme &&
selectedPdfs.studentResponses
);
};
if (isLoading) {
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Typography>Loading...</Typography>
</Box>
);
}
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Stack
spacing={4}
alignItems="center"
sx={{
maxWidth: '800px',
width: '100%',
p: 3
}}
>
<Typography variant="h5">Select PDF Files</Typography>
<Stack
direction="row"
sx={{
width: '100%',
justifyContent: 'center',
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
}}
>
<Button
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
onClick={() => createFileInput('examPaper')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
</Button>
<Button
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
onClick={() => createFileInput('markScheme')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
</Button>
<Button
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
onClick={() => createFileInput('studentResponses')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
</Button>
</Stack>
{allPdfsSelected() && (
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
<Button
variant="contained"
color="primary"
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
sx={{
minWidth: '180px',
height: '48px'
}}
>
Continue
</Button>
</Box>
)}
</Stack>
</Box>
);
}
@@ -0,0 +1,61 @@
.CCExamMarker {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.CCExamMarker .CCPdfPicker {
position: absolute;
inset: 1rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
gap: 1rem;
}
.CCExamMarker .CCPdfBgRenderer {
position: absolute;
pointer-events: none;
}
.CCExamMarker .CCPdfBgRenderer img {
position: absolute;
}
.CCExamMarker .PageOverlayScreen-screen {
pointer-events: none;
z-index: -1;
fill: var(--color-background);
fill-opacity: 0.8;
stroke: none;
}
.CCExamMarker .PageOverlayScreen-outline {
position: absolute;
pointer-events: none;
z-index: -1;
box-shadow: var(--shadow-2);
}
.CCExamMarker .CCExportPdfButton {
font: inherit;
background: var(--color-primary);
border: none;
color: var(--color-selected-contrast);
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 6px;
margin: 6px;
margin-bottom: 0;
pointer-events: all;
z-index: var(--layer-panels);
border: 2px solid var(--color-background);
cursor: pointer;
}
.CCExamMarker .CCExportPdfButton:hover {
filter: brightness(1.1);
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
export interface PdfPage {
src: string;
bounds: Box;
assetId: TLAssetId;
shapeId: TLShapeId;
}
export interface Pdf {
name: string;
pages: PdfPage[];
source: string | ArrayBuffer;
}
export interface ExamPdfs {
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
}
export type ExamPdfState =
| {
phase: 'pick';
}
| {
phase: 'edit';
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
};
export interface StudentResponse {
studentId: string;
pageStart: number;
pageEnd: number;
}
export interface ExamMetadata {
totalPages: number;
pagesPerStudent: number;
totalStudents: number;
studentResponses: StudentResponse[];
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../debugConfig';
interface LaunchParams {
files: FileSystemFileHandle[];
}
interface LaunchQueue {
setConsumer(callback: (params: LaunchParams) => Promise<void>): void;
}
interface WindowWithLaunchQueue extends Window {
launchQueue: LaunchQueue;
}
const ShareHandler = () => {
const navigate = useNavigate();
useEffect(() => {
const processSharedData = async () => {
try {
// Handle files shared through Web Share Target API
if ('launchQueue' in window) {
(window as WindowWithLaunchQueue).launchQueue.setConsumer(async (launchParams: LaunchParams) => {
if (!launchParams.files.length) {
logger.debug('share-handler', 'No files shared');
return;
}
for (const fileHandle of launchParams.files) {
const file = await fileHandle.getFile();
logger.info('share-handler', 'Processing shared file', {
name: file.name,
type: file.type,
size: file.size
});
// Navigate to single player with the shared file
// You might want to modify this based on your needs
navigate('/single-player', {
state: {
sharedFile: file
}
});
}
});
}
// Handle URL parameters for text/url sharing
const urlParams = new URLSearchParams(window.location.search);
const title = urlParams.get('title');
const text = urlParams.get('text');
const url = urlParams.get('url');
if (title || text || url) {
logger.info('share-handler', 'Processing shared content', {
title,
text,
url
});
// Navigate to single player with the shared content
navigate('/single-player', {
state: {
sharedContent: { title, text, url }
}
});
}
} catch (error) {
logger.error('share-handler', 'Error processing shared content', { error });
}
};
processSharedData();
}, [navigate]);
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}>
Processing shared content...
</div>
);
};
export default ShareHandler;
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { Tldraw } from '@tldraw/tldraw';
import '@tldraw/tldraw/tldraw.css';
const TLDrawCanvas: React.FC = () => {
return (
<div style={{ width: '100%', height: '100%' }}>
<Tldraw persistenceKey="classroom-copilot-landing-page" />
</div>
);
};
export default TLDrawCanvas;
+469
View File
@@ -0,0 +1,469 @@
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
} from '@tldraw/tldraw';
import { useAuth } from '../../contexts/AuthContext';
import { useTLDraw } from '../../contexts/TLDrawContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
// Tldraw utils
import { customAssets } from '../../utils/tldraw/assets';
import { devEmbeds } from '../../utils/tldraw/embeds';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { devTools } from '../../utils/tldraw/tools';
import { customSchema } from '../../utils/tldraw/schemas';
// Layout
import { HEADER_HEIGHT } from '../Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
interface EventFilter {
type: 'all' | 'ui' | 'store' | 'canvas';
subType?: string;
enabled: boolean;
}
interface EventFilters {
mode: 'all' | 'specific';
filters: {
[key: string]: EventFilter;
};
}
const EventMonitoringControls: React.FC<{
filters: EventFilters;
setFilters: (filters: EventFilters) => void;
onClear: () => void;
}> = ({ filters, setFilters, onClear }) => {
const handleModeChange = (mode: 'all' | 'specific') => {
setFilters({ ...filters, mode });
};
const handleFilterChange = (key: string, enabled: boolean) => {
setFilters({
...filters,
filters: {
...filters.filters,
[key]: { ...filters.filters[key], enabled }
}
});
};
return (
<div className="event-monitor-controls">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="mode-selector">
<label>
<input
type="radio"
checked={filters.mode === 'all'}
onChange={() => handleModeChange('all')}
/>
Monitor All Events
</label>
<label>
<input
type="radio"
checked={filters.mode === 'specific'}
onChange={() => handleModeChange('specific')}
/>
Monitor Specific Events
</label>
</div>
<button
onClick={onClear}
style={{
padding: '4px 8px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Clear Logs
</button>
</div>
{filters.mode === 'specific' && (
<div className="specific-filters">
<select
onChange={(e) => handleFilterChange(e.target.value, true)}
value=""
>
<option value="" disabled>Add Event Filter</option>
<optgroup label="UI Events">
<option value="ui-selection">Selection Changes</option>
<option value="ui-tool">Tool Changes</option>
<option value="ui-viewport">Viewport Changes</option>
</optgroup>
<optgroup label="Store Events">
<option value="store-shapes">Shape Updates</option>
<option value="store-bindings">Binding Updates</option>
<option value="store-assets">Asset Updates</option>
</optgroup>
<optgroup label="Canvas Events">
<option value="canvas-pointer">Pointer Events</option>
<option value="canvas-camera">Camera Events</option>
<option value="canvas-selection">Selection Events</option>
</optgroup>
</select>
<div className="active-filters">
{Object.entries(filters.filters)
.filter(([, filter]) => filter.enabled)
.map(([key]) => (
<div key={key} className="filter-tag">
{key}
<button onClick={() => handleFilterChange(key, false)}>×</button>
</div>
))}
</div>
</div>
)}
</div>
);
};
const MAX_EVENTS = 100; // Limit visible events to last 100
const EventDisplay: React.FC<{ events: Array<{ type: string; data: string; timestamp: string }> }> =
({ events }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
}, [events]);
// Only show the last MAX_EVENTS events
const visibleEvents = useMemo(() =>
events.slice(-MAX_EVENTS),
[events]
);
return (
<div
ref={scrollContainerRef}
className="event-display"
style={{
flex: 1,
padding: 8,
background: '#ddd',
borderLeft: 'solid 2px #333',
fontFamily: 'monospace',
fontSize: 12,
overflow: 'auto',
scrollBehavior: 'smooth',
}}
>
{visibleEvents.length === MAX_EVENTS && (
<div style={{
padding: '4px 8px',
marginBottom: 8,
backgroundColor: '#fff3cd',
color: '#856404',
borderRadius: 4,
fontSize: 11,
}}>
Showing last {MAX_EVENTS} events only
</div>
)}
{visibleEvents.map((event, i) => (
<pre
key={event.timestamp + i}
style={{
borderBottom: '1px solid #000',
marginBottom: 0,
paddingBottom: '12px',
backgroundColor: getEventTypeColor(event.type),
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
<span className="event-timestamp">{event.timestamp}</span>
<span className="event-type">[{event.type}]</span>
{event.data}
</pre>
))}
</div>
);
};
const getEventTypeColor = (type: string): string => {
switch (type) {
case 'ui':
return '#e8f0fe'; // Light blue
case 'store':
return '#fef3e8'; // Light orange
case 'canvas':
return '#f0fee8'; // Light green
default:
return 'transparent';
}
};
export default function DevPage() {
const { user } = useAuth();
const navigate = useNavigate();
const { tldrawPreferences, initializePreferences, setTldrawPreferences } = useTLDraw();
const [events, setEvents] = useState<Array<{ type: 'ui' | 'store' | 'canvas'; data: string; timestamp: string; }>>([]);
const [eventFilters, setEventFilters] = useState<EventFilters>({ mode: 'all', filters: {} });
const [logPanelWidth, setLogPanelWidth] = useState(30); // Width in percentage
const editorRef = useRef<Editor | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
document.body.style.cursor = 'col-resize';
const handleDragMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const windowWidth = window.innerWidth;
const newWidth = (e.clientX / windowWidth) * 100;
// Limit the range to between 20% and 80%
const clampedWidth = Math.min(Math.max(newWidth, 20), 80);
setLogPanelWidth(100 - clampedWidth);
};
const handleDragUp = () => {
isDraggingRef.current = false;
document.body.style.cursor = 'default';
window.removeEventListener('mousemove', handleDragMove);
window.removeEventListener('mouseup', handleDragUp);
};
window.addEventListener('mousemove', handleDragMove);
window.addEventListener('mouseup', handleDragUp);
}, []);
// Create tldraw user
const tldrawUser = useTldrawUser({
userPreferences: {
id: user?.id ?? 'dev-user',
name: user?.display_name ?? 'Unknown User',
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
// Create store
const store = useMemo(() => localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
}), []);
// Initialize preferences when user is available
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.debug('dev-page', '🔄 Initializing preferences for user', { userId: user.id });
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Redirect if no user
useEffect(() => {
if (!user) {
logger.info('dev-page', '🚪 Redirecting to home - no user logged in');
navigate('/');
}
}, [user, navigate]);
const shouldCaptureEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
if (eventFilters.mode === 'all') return true;
// Check specific filters
return Object.entries(eventFilters.filters)
.some(([key, filter]) => {
if (!filter.enabled) return false;
const [filterType, filterSubType] = key.split('-');
if (filterType !== type) return false;
// Match specific event subtypes
switch (filterType) {
case 'ui':
return data.includes(filterSubType);
case 'store':
return data.includes(`"type":"${filterSubType}"`);
case 'canvas':
return data.includes(`Canvas Event: ${filterSubType}`);
default:
return false;
}
});
}, [eventFilters]);
const addEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
if (!shouldCaptureEvent(type, data)) return;
setEvents(prevEvents => {
const newEvents = [...prevEvents, {
type,
data,
timestamp: new Date().toISOString()
}];
// Keep last 2 * MAX_EVENTS in state to allow some scrollback
return newEvents.slice(-(MAX_EVENTS * 2));
});
}, [shouldCaptureEvent]);
const handleUiEvent = useCallback((name: string, data: unknown) => {
const eventString = `UI Event: ${name} ${JSON.stringify(data)}`;
addEvent('ui', eventString);
console.log(eventString);
}, [addEvent]);
const handleCanvasEvent = useCallback((editor: Editor) => {
logger.trace('dev-page', '🎨 Canvas editor mounted');
editor.on('change', () => {
const camera = editor.getCamera();
logger.trace('dev-page', '🎥 Camera changed', { camera });
addEvent('canvas', `Canvas Event: camera ${JSON.stringify(camera)}`);
});
editor.on('change', () => {
const selectedIds = editor.getSelectedShapeIds();
if (selectedIds.length > 0) {
logger.trace('dev-page', '🔍 Selection changed', { selectedIds });
addEvent('canvas', `Canvas Event: selection ${JSON.stringify(selectedIds)}`);
}
});
editor.on('event', (info) => {
if (info.type === 'pointer') {
const point = editor.inputs.currentPagePoint;
logger.trace('dev-page', '👆 Pointer event', { point });
addEvent('canvas', `Canvas Event: pointer ${JSON.stringify(point)}`);
}
});
}, [addEvent]);
useEffect(() => {
if (store) {
const cleanupFn = store.listen((info) => {
const eventString = `Store Event: ${info.source} ${JSON.stringify(info.changes)}`;
addEvent('store', eventString);
console.log(eventString);
});
return () => cleanupFn();
}
}, [store, addEvent]);
useEffect(() => {
if (scrollContainerRef.current) {
const scrollContainer = scrollContainerRef.current;
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}, [events]);
const clearEvents = useCallback(() => {
setEvents([]);
}, []);
if (!user) {
logger.info('dev-page', '🚫 Rendering null - no user');
return null;
}
return (
<div style={{
display: 'flex',
width: '100%',
height: `calc(100vh - ${HEADER_HEIGHT}px)`,
position: 'fixed',
top: `${HEADER_HEIGHT}px`
}}>
<div style={{
width: `${100 - logPanelWidth}%`,
height: '100%',
position: "absolute",
left: 0,
overflow: 'hidden'
}}>
<Tldraw
user={tldrawUser}
store={store}
onMount={(editor) => {
editorRef.current = editor;
handleCanvasEvent(editor);
logger.info('system', '🎨 Tldraw mounted', {
editorId: editor.store.id
});
}}
onUiEvent={handleUiEvent}
tools={devTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
embeds={devEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
/>
</div>
<div
style={{
width: '5px',
height: '100%',
position: 'absolute',
left: `${100 - logPanelWidth}%`,
transform: 'translateX(-50%)',
cursor: 'col-resize',
backgroundColor: 'transparent',
zIndex: 1000,
}}
onMouseDown={handleDragStart}
>
<div style={{
width: '1px',
height: '100%',
backgroundColor: '#333',
margin: '0 auto',
}} />
</div>
<div
style={{
width: `${logPanelWidth}%`,
height: '100%',
position: 'absolute',
right: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<EventMonitoringControls
filters={eventFilters}
setFilters={setEventFilters}
onClear={clearEvents}
/>
<EventDisplay events={events} />
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useMemo, useRef } from 'react';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLAnyShapeUtilConstructor
} from '@tldraw/tldraw';
// App context
import { useTLDraw } from '../../contexts/TLDrawContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { devEmbeds } from '../../utils/tldraw/embeds';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { devTools } from '../../utils/tldraw/tools';
import { customSchema } from '../../utils/tldraw/schemas';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
const devUserId = 'dev-user';
export default function TLDrawDevPage() {
// 1. All context hooks first
const {
tldrawPreferences,
initializePreferences,
presentationMode,
setTldrawPreferences
} = useTLDraw();
// 2. All refs
const editorRef = useRef<Editor | null>(null);
// 4. All memos
const tldrawUser = useTldrawUser({
userPreferences: {
id: devUserId,
name: 'Dev User',
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
const store = useMemo(() => localStoreService.getStore({
schema: customSchema,
shapeUtils: [...allShapeUtils] as TLAnyShapeUtilConstructor[],
bindingUtils: allBindingUtils
}), []);
// Initialize preferences when user is available
useEffect(() => {
if (!tldrawPreferences) {
logger.debug('single-player-page', '🔄 Initializing preferences');
initializePreferences(devUserId);
}
}, [tldrawPreferences, initializePreferences]);
// Load initial data when user node is available
useEffect(() => {
if (!tldrawUser) {
return;
}
}, [tldrawUser, store]);
// Handle presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
logger.info('presentation', '🔄 Presentation mode changed', {
presentationMode,
editorExists: !!editorRef.current
});
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
logger.info('presentation', '🧹 Cleaning up presentation mode');
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Modify the render logic to use presentationMode
const uiOverrides = getUiOverrides(presentationMode);
const uiComponents = getUiComponents(presentationMode);
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Tldraw
user={tldrawUser}
store={store}
tools={devTools}
shapeUtils={allShapeUtils as TLAnyShapeUtilConstructor[]}
bindingUtils={allBindingUtils}
components={uiComponents}
overrides={uiOverrides}
embeds={devEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
onMount={(editor) => {
logger.info('system', '🎨 Tldraw mounted', {
editorId: editor.store.id,
presentationMode
});
editorRef.current = editor;
}}
/>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES,
} from '@tldraw/tldraw';
import { useSync } from '@tldraw/sync';
// App context
import { useAuth } from '../../contexts/AuthContext';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
// Tldraw services
import { multiplayerOptions } from '../../services/tldraw/optionsService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { createSyncConnectionOptions, handleExternalAsset } from '../../services/tldraw/syncService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { multiplayerTools } from '../../utils/tldraw/tools';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { customSchema } from '../../utils/tldraw/schemas';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http')
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
export default function TldrawMultiUser() {
const { user } = useAuth();
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const {
tldrawPreferences,
setTldrawPreferences,
initializePreferences,
presentationMode
} = useTLDraw();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const editorRef = useRef<Editor | null>(null);
// Get room ID from URL params
const roomId = searchParams.get('room') || 'multiplayer';
// Memoize user information to ensure consistency
const userInfo = useMemo(() => ({
id: user?.id ?? '',
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]);
// Create editor user with memoization
const editorUser = useTldrawUser({
userPreferences: {
id: userInfo.id,
name: userInfo.name,
color: userInfo.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
const connectionOptions = useMemo(() => createSyncConnectionOptions({
userId: userInfo.id,
displayName: userInfo.name,
color: userInfo.color,
roomId,
baseUrl: SYNC_WORKER_URL
}), [userInfo, roomId]);
const store = useSync({
...connectionOptions,
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils,
userInfo: {
id: userInfo.id,
name: userInfo.name,
color: userInfo.color
}
});
// Log connection status changes
useEffect(() => {
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
status: store.status,
connectionOptions
});
}, [store.status, connectionOptions]);
// Effect for initializing preferences
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.info('multiplayer-page', '🔄 Initializing preferences');
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Effect for redirecting if user is not authenticated
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
// Effect for presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Memoize UI overrides and components
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
// Render conditionally to avoid unnecessary rerenders
if (!user) {
return null;
}
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)'
}}>
<div style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}>
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
</div>
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Tldraw
user={editorUser}
store={store.store}
onMount={(editor) => {
editorRef.current = editor;
editor.registerExternalAssetHandler('url', async ({ url }: { url: string }) => {
return handleExternalAsset(SYNC_WORKER_URL, url);
});
}}
options={multiplayerOptions}
embeds={multiplayerEmbeds}
tools={multiplayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
overrides={uiOverrides}
components={uiComponents}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
/>
</div>
);
}
+514
View File
@@ -0,0 +1,514 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore,
TLStoreWithStatus
} from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useUser } from '../../contexts/UserContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { singlePlayerTools } from '../../utils/tldraw/tools';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { singlePlayerEmbeds } from '../../utils/tldraw/embeds';
import { customSchema } from '../../utils/tldraw/schemas';
// Navigation
import { useNavigationStore } from '../../stores/navigationStore';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
import { CircularProgress, Alert, Snackbar } from '@mui/material';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { NodeData } from '../../types/graph-shape';
import { NavigationNode } from '../../types/navigation';
interface LoadingState {
status: 'ready' | 'loading' | 'error';
error: string;
}
export default function SinglePlayerPage() {
// Context hooks with initialization states
const { user, loading: userLoading } = useUser();
const {
tldrawPreferences,
initializePreferences,
presentationMode,
setTldrawPreferences
} = useTLDraw();
const routerNavigate = useNavigate();
const location = useLocation();
// Navigation store
const { context } = useNavigationStore();
// Refs
const editorRef = useRef<Editor | null>(null);
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
// State
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isEditorReady, setIsEditorReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
// TLDraw user preferences
const tldrawUser = useTldrawUser({
userPreferences: {
id: user?.id ?? '',
name: user?.display_name,
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
// Initialize store
useEffect(() => {
if (!isEditorReady) {
logger.debug('single-player-page', '⏳ Waiting for editor to be ready');
return;
}
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
return;
}
if (!editorRef.current) {
logger.debug('single-player-page', '⏳ Waiting for editor ref');
return;
}
logger.info('single-player-page', '🔄 Starting store initialization', {
isEditorReady,
hasUser: !!user,
userType: user.user_type,
username: user.username
});
const initializeStoreAndSnapshot = async () => {
try {
setLoadingState({ status: 'loading', error: '' });
// 1. Create store
logger.debug('single-player-page', '🔄 Creating TLStore');
const newStore = localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
});
logger.debug('single-player-page', '✅ TLStore created');
// 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
tldraw_snapshot: context.node.tldraw_snapshot,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.tldraw_snapshot,
user.user_db_name,
newStore,
setLoadingState
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 4. Set up auto-save
newStore.listen(() => {
if (snapshotServiceRef.current && context.node) {
logger.debug('single-player-page', '💾 Auto-saving changes');
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Auto-save failed', error);
});
}
});
// 5. Update store state
setStore(newStore);
setLoadingState({ status: 'ready', error: '' });
logger.info('single-player-page', '✅ Store initialization complete');
// 6. Handle cleanup
return () => {
logger.debug('single-player-page', '🧹 Starting cleanup');
if (snapshotServiceRef.current) {
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Final save failed', error);
});
snapshotServiceRef.current.clearCurrentNode();
snapshotServiceRef.current = null;
}
newStore.dispose();
setStore(undefined);
logger.debug('single-player-page', '🧹 Cleanup complete');
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
logger.error('single-player-page', '❌ Store initialization failed', error);
setLoadingState({ status: 'error', error: errorMessage });
return undefined;
}
};
initializeStoreAndSnapshot();
}, [isEditorReady, user, context.node, editorRef.current]);
// Handle initial node placement
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
return;
}
try {
setLoadingState({ status: 'loading', error: '' });
// Center the node
const nodeData = await loadNodeData(context.node);
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
setIsInitialLoad(false);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to place initial node', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to place initial node'
});
}
};
placeInitialNode();
}, [context.node, store, isInitialLoad]);
// Handle navigation changes
useEffect(() => {
const handleNodeChange = async () => {
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
return;
}
// We can safely assert these types because we've checked for null above
const editor = editorRef.current as Editor;
const snapshotService = snapshotServiceRef.current;
const currentNode = context.node;
try {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: currentNode.id,
tldraw_snapshot: currentNode.tldraw_snapshot,
isInitialLoad
});
// Get the previous node from navigation history
const previousNode = context.history.currentIndex > 0
? context.history.nodes[context.history.currentIndex - 1]
: null;
// Handle navigation in snapshot service
await snapshotService.handleNavigationStart(previousNode, currentNode);
// Center the node on canvas
const nodeData = await loadNodeData(currentNode);
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to load node data', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load node data'
});
}
};
handleNodeChange();
}, [context.node?.id, context.history, store]);
// Initialize preferences when user is available
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.debug('single-player-page', '🔄 Initializing preferences for user', { userId: user.id });
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Redirect if no user or incorrect role
useEffect(() => {
if (!user || user.user_type !== 'admin') {
logger.info('single-player-page', '🚪 Redirecting to home - no user or incorrect role', {
hasUser: !!user,
userType: user?.user_type
});
routerNavigate('/', { replace: true });
}
}, [user, routerNavigate]);
// Handle presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
logger.info('presentation', '🔄 Presentation mode changed', {
presentationMode,
editorExists: !!editorRef.current
});
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
logger.info('presentation', '🧹 Cleaning up presentation mode');
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Handle shared content
useEffect(() => {
const handleSharedContent = async () => {
if (!editorRef.current || !location.state) {
return;
}
const editor = editorRef.current;
const { sharedFile, sharedContent } = location.state as {
sharedFile?: File;
sharedContent?: {
title?: string;
text?: string;
url?: string;
};
};
if (sharedFile) {
logger.info('single-player-page', '📤 Processing shared file', {
name: sharedFile.name,
type: sharedFile.type
});
try {
// Handle different file types
if (sharedFile.type.startsWith('image/')) {
const imageUrl = URL.createObjectURL(sharedFile);
await editor.createShape({
type: 'image',
props: {
url: imageUrl,
w: 320,
h: 240,
name: sharedFile.name
}
});
URL.revokeObjectURL(imageUrl);
} else if (sharedFile.type === 'application/pdf') {
// Handle PDF (you might want to implement PDF handling)
logger.info('single-player-page', '📄 PDF handling not implemented yet');
} else if (sharedFile.type === 'text/plain') {
const text = await sharedFile.text();
editor.createShape({
type: 'text',
props: { text }
});
}
} catch (error) {
logger.error('single-player-page', '❌ Error processing shared file', { error });
}
}
if (sharedContent) {
logger.info('single-player-page', '📤 Processing shared content', { sharedContent });
const { title, text, url } = sharedContent;
let contentText = '';
if (title) {
contentText += `${title}\n`;
}
if (text) {
contentText += `${text}\n`;
}
if (url) {
contentText += url;
}
if (contentText) {
editor.createShape({
type: 'text',
props: { text: contentText }
});
}
}
};
handleSharedContent();
}, [location.state]);
// Modify the render logic to use presentationMode
const uiOverrides = getUiOverrides(presentationMode);
const uiComponents = getUiComponents(presentationMode);
// Show loading state if user context is still loading
if (userLoading) {
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--color-background)'
}}>
<CircularProgress />
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
zIndex: 1000,
}}>
<CircularProgress />
</div>
)}
{/* Error snackbar */}
<Snackbar
open={loadingState.status === 'error'}
autoHideDuration={6000}
onClose={() => setLoadingState({ status: 'ready', error: '' })}
>
<Alert severity="error" onClose={() => setLoadingState({ status: 'ready', error: '' })}>
{loadingState.error}
</Alert>
</Snackbar>
<Tldraw
user={tldrawUser}
store={store}
tools={singlePlayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
components={uiComponents}
overrides={uiOverrides}
embeds={singlePlayerEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
onMount={(editor) => {
logger.info('single-player-page', '🎨 Starting Tldraw mount');
try {
if (!editor) {
logger.error('single-player-page', '❌ Editor is null in onMount');
return;
}
editorRef.current = editor;
logger.debug('single-player-page', '✅ Editor ref set');
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id,
presentationMode,
isEditorReady: true
});
} catch (error) {
logger.error('single-player-page', '❌ Error in onMount', error);
}
}}
/>
</div>
);
}
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// 1. Always fetch fresh data
const dbName = UserNeoDBService.getNodeDatabaseName(node);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: fetchedData.node_data.title || node.label,
w: 500,
h: 350,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
};
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Typography, Button, Container, useTheme } from "@mui/material";
import { useAuth } from "../../contexts/AuthContext";
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { logger } from '../../debugConfig';
function NotFound() {
const theme = useTheme();
const navigate = useNavigate();
const { user } = useAuth();
useEffect(() => {
logger.debug('not-found', '🔄 Not Found page rendered', {
hasUser: !!user,
userId: user?.id
});
}, [user]);
const handleReturn = () => {
const returnPath = user ? '/single-player' : '/';
logger.debug('not-found', '🔄 Navigating to return path', { returnPath });
navigate(returnPath);
};
return (
<Container maxWidth="sm">
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
textAlign: 'center',
gap: 3
}}
>
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
<Typography variant="h2" component="h1" gutterBottom>
404
</Typography>
<Typography variant="h5" gutterBottom>
Page Not Found
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
The page you're looking for doesn't exist or has been moved.
</Typography>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleReturn}
>
Return to {user ? 'Canvas' : 'Home'}
</Button>
</Box>
</Container>
);
}
export default NotFound;
+530
View File
@@ -0,0 +1,530 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { EventContentArg, EventClickArg, CalendarOptions } from '@fullcalendar/core';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import multiMonthPlugin from '@fullcalendar/multimonth'; // Import the multiMonth plugin for year view
import listPlugin from '@fullcalendar/list';
import { useAuth } from '../../contexts/AuthContext';
import { useNeoUser } from '../../contexts/NeoUserContext';
import { FaEllipsisV } from 'react-icons/fa';
import { logger } from '../../debugConfig';
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
interface Event {
id: string;
title: string;
start: string;
end: string;
groupId?: string;
extendedProps?: {
subjectClass: string;
color: string;
periodCode: string;
tldraw_snapshot?: string;
};
}
function lightenColor(color: string, amount: number): string {
// Remove the '#' if it exists
color = color.replace(/^#/, '');
// Parse the color
let r = parseInt(color.slice(0, 2), 16);
let g = parseInt(color.slice(2, 4), 16);
let b = parseInt(color.slice(4, 6), 16);
// Convert to HSL
const [h, s, l] = rgbToHsl(r, g, b);
// Adjust the lightness based on the current lightness
const newL = l < 0.5 ? l + (1 - l) * amount : l + (1 - l) * amount * 0.5;
// Convert back to RGB
[r, g, b] = hslToRgb(h, s, newL);
// Convert to hex and return
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s, l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h, s, l];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) {
t += 1;
}
if (t > 1) {
t -= 1;
}
if (t < 1/6) {
return p + (q - p) * 6 * t;
}
if (t < 1/2) {
return q;
}
if (t < 2/3) {
return p + (q - p) * (2/3 - t) * 6;
}
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
const CalendarPage: React.FC = () => {
const [events, setEvents] = useState<Event[]>([]);
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
const { user } = useAuth();
const calendarRef = useRef<FullCalendar>(null);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const [hiddenSubjectClassDivs, setHiddenSubjectClassDivs] = useState<string[]>([]);
const [hiddenPeriodCodeDivs, setHiddenPeriodCodeDivs] = useState<string[]>([]);
const [hiddenTimeDivs, setHiddenTimeDivs] = useState<string[]>([]);
const [eventRange, setEventRange] = useState<{ start: Date | null; end: Date | null }>({ start: null, end: null });
const { workerNode, isLoading, error, workerDbName } = useNeoUser();
const getEventRange = useCallback((events: Event[]) => {
if (events.length === 0) {
return { start: null, end: null };
}
let start = new Date(events[0].start);
let end = new Date(events[0].end);
events.forEach(event => {
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
if (eventStart < start) {
start = eventStart;
}
if (eventEnd > end) {
end = eventEnd;
}
});
// Adjust start to the beginning of its month and end to the end of its month
start.setDate(1);
end.setMonth(end.getMonth() + 1, 0);
return { start, end };
}, []);
const fetchEvents = useCallback(async () => {
if (!user || isLoading || error || !workerNode?.nodeData) {
if (error) {
logger.error('calendar', 'NeoUser context error', { error });
}
return;
}
try {
logger.debug('calendar', 'Fetching events', {
unique_id: workerNode.nodeData.unique_id,
school_db_name: workerDbName
});
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
workerNode.nodeData.unique_id,
workerDbName || ''
);
const transformedEvents = events.map(event => ({
...event,
extendedProps: {
...event.extendedProps,
tldraw_snapshot: workerNode?.nodeData?.tldraw_snapshot
}
}));
setEvents(transformedEvents);
const classes: string[] = [];
transformedEvents.forEach((event: Event) => {
if (event.extendedProps?.subjectClass && !classes.includes(event.extendedProps.subjectClass)) {
classes.push(event.extendedProps.subjectClass);
}
});
setSelectedClasses(classes);
const range = getEventRange(transformedEvents);
setEventRange(range);
} catch (error) {
logger.error('calendar', 'Error fetching events', { error });
}
}, [user, workerNode, workerDbName, isLoading, error, getEventRange]);
useEffect(() => {
fetchEvents();
}, [fetchEvents]);
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot;
if (tldraw_snapshot) {
// TODO: Implement tldraw_snapshot retrieval from storage API
// For now, we'll just log it
console.log('TLDraw snapshot:', tldraw_snapshot);
}
}, []);
const filteredEvents = useMemo(() =>
events.filter(event =>
selectedClasses.includes(event.extendedProps?.subjectClass || '')
), [events, selectedClasses]
);
const handleResize = useCallback(() => {
if (calendarRef.current) {
calendarRef.current.getApi().updateSize();
}
}, []);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
const toggleDropdown = useCallback((eventId: string) => {
setOpenDropdownId(openDropdownId === eventId ? null : eventId);
}, [openDropdownId]);
const toggleSubjectClassDivVisibility = useCallback((subjectClass: string) => {
setHiddenSubjectClassDivs(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
}, []);
const togglePeriodCodeDivVisibility = useCallback((subjectClass: string) => {
setHiddenPeriodCodeDivs(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
}, []);
const toggleTimeDivVisibility = useCallback((subjectClass: string) => {
setHiddenTimeDivs(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
}, []);
const hideSubjectClassFromView = useCallback((subjectClass: string) => {
setSelectedClasses(prev => prev.filter(c => c !== subjectClass));
}, []);
const toggleAllDivs = useCallback((subjectClass: string, hide: boolean) => {
const updateHiddenDivs = (prev: string[]) =>
hide ? [...prev, subjectClass] : prev.filter(c => c !== subjectClass);
setHiddenSubjectClassDivs(updateHiddenDivs);
setHiddenPeriodCodeDivs(updateHiddenDivs);
setHiddenTimeDivs(updateHiddenDivs);
}, []);
const areAllDivsHidden = useCallback((subjectClass: string) => {
return hiddenSubjectClassDivs.includes(subjectClass) &&
hiddenPeriodCodeDivs.includes(subjectClass) &&
hiddenTimeDivs.includes(subjectClass);
}, [hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs]);
const renderEventContent = useCallback((eventInfo: EventContentArg) => {
const { event } = eventInfo;
const subjectClass = event.extendedProps?.subjectClass || 'Subject Class';
const originalColor = event.extendedProps?.color || '#ffffff';
const lightenedColor = lightenColor(originalColor, 0.9);
const eventStyle = {
backgroundColor: lightenedColor,
color: '#000',
padding: '4px 6px',
borderRadius: '6px',
fontSize: '1.0em',
overflow: 'visible',
display: 'flex',
flexDirection: 'column' as const,
height: '100%',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
border: `2px solid ${originalColor}`,
position: 'relative' as const,
};
const titleStyle = {
fontWeight: 'bold' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
paddingRight: '20px',
};
const contentStyle = {
fontSize: '0.8em',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis'
};
const ellipsisStyle = {
position: 'absolute' as const,
top: '4px',
right: '4px',
cursor: 'pointer',
zIndex: 10,
};
return (
<div className={`custom-event-content ${openDropdownId === event.id ? 'event-with-dropdown' : ''}`} style={eventStyle}>
<div style={titleStyle}>{event.title}</div>
<div style={ellipsisStyle}>
<FaEllipsisV onClick={(e) => {
e.stopPropagation();
toggleDropdown(event.id);
}} />
</div>
{openDropdownId === event.id && (
<div className="event-dropdown" style={{ position: 'absolute'}}>
<div onClick={(e) => {
e.stopPropagation();
hideSubjectClassFromView(subjectClass);
setOpenDropdownId(null);
}}>
Hide this class from view
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleAllDivs(subjectClass, !areAllDivsHidden(subjectClass));
setOpenDropdownId(null);
}}>
{areAllDivsHidden(subjectClass) ? 'Show' : 'Hide'} all divs
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleSubjectClassDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenSubjectClassDivs.includes(subjectClass) ? 'Show' : 'Hide'} subject class
</div>
<div onClick={(e) => {
e.stopPropagation();
togglePeriodCodeDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenPeriodCodeDivs.includes(subjectClass) ? 'Show' : 'Hide'} period code
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleTimeDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenTimeDivs.includes(subjectClass) ? 'Show' : 'Hide'} time
</div>
</div>
)}
{!hiddenSubjectClassDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-subject-class">{subjectClass}</div>
)}
{!hiddenPeriodCodeDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-period">{event.extendedProps?.periodCode || 'Period Code'}</div>
)}
{!hiddenTimeDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-time">
{event.start?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
{event.end?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
)}
</div>
);
}, [openDropdownId, hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs, toggleDropdown, hideSubjectClassFromView, toggleAllDivs, areAllDivsHidden, toggleSubjectClassDivVisibility, togglePeriodCodeDivVisibility, toggleTimeDivVisibility]);
const calendarOptions: CalendarOptions = useMemo(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, multiMonthPlugin, listPlugin],
initialView: "timeGridWeek",
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'viewToggle filterClassesButton'
},
customButtons: {
filterClassesButton: {
text: 'Filter Classes',
click: () => {} // We'll implement this differently later
},
viewToggle: {
text: 'Change View',
click: () => {} // We'll implement this differently later
}
},
views: {
dayGridYear: {
type: 'dayGrid',
duration: { years: 1 },
buttonText: 'Year Grid',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
dayGridMonth: {
buttonText: 'Month',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
timeGridWeek: {
buttonText: 'Week',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
timeGridDay: {
buttonText: 'Day',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listYear: {
buttonText: 'List Year',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listMonth: {
buttonText: 'List Month',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listWeek: {
buttonText: 'List Week',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
listDay: {
buttonText: 'List Day',
visibleRange: (currentDate: Date) => ({
start: eventRange.start || currentDate,
end: eventRange.end || currentDate
}),
},
},
validRange: eventRange.start && eventRange.end ? {
start: eventRange.start,
end: eventRange.end
} : undefined,
events: filteredEvents,
height: "100%",
slotMinTime: "08:00:00",
slotMaxTime: "17:00:00",
allDaySlot: false,
expandRows: true,
slotEventOverlap: false,
slotDuration: "00:30:00",
slotLabelInterval: "01:00",
eventContent: renderEventContent,
eventClassNames: (arg: { event: { extendedProps?: { subjectClass?: string } } }) =>
[arg.event.extendedProps?.subjectClass || ''],
eventDidMount: (arg: { event: { extendedProps?: { color?: string }; id: string }; el: HTMLElement }) => {
if (arg.event.extendedProps?.color) {
const originalColor = arg.event.extendedProps.color;
const lightenedColor = lightenColor(originalColor, 0.4);
arg.el.style.backgroundColor = lightenedColor;
arg.el.style.borderColor = originalColor;
}
const updateEventContent = () => {
const height = arg.el.offsetHeight;
const contentElements = arg.el.querySelectorAll('.custom-event-content > div:not(.event-dropdown)');
contentElements.forEach((el, index) => {
const element = el as HTMLElement;
if (index === 0 || index === 1) {
element.style.display = 'block';
} else if (height >= 40 && index === 2) {
element.style.display = 'block';
} else if (height >= 60 && index === 3) {
element.style.display = 'block';
} else if (height >= 80 && index === 4) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
};
updateEventContent();
const resizeObserver = new ResizeObserver(updateEventContent);
resizeObserver.observe(arg.el);
return () => resizeObserver.disconnect();
},
eventClick: handleEventClick,
}), [eventRange.start, eventRange.end, filteredEvents, renderEventContent, handleEventClick]);
if (!user) {
console.log('User not logged in');
return <div>Please log in to view your calendar.</div>;
}
return (
<div className="calendar-page">
<div className="calendar-container" style={{ height: '100vh', position: 'relative' }}>
<FullCalendar
{...calendarOptions}
ref={calendarRef}
/>
</div>
</div>
);
};
export default CalendarPage;
+123
View File
@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import {
Container,
Typography,
Paper,
Box,
Button,
Alert
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { useNeoUser } from '../../contexts/NeoUserContext';
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
import { CCTeacherNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
const SettingsPage: React.FC = () => {
const { user, user_role } = useAuth();
const { userNode, workerNode } = useNeoUser();
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Check if user is a teacher (includes both email and MS teachers)
const isTeacher = user_role?.includes('teacher');
const handleTimetableUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
setIsUploading(true);
setUploadError(null);
setUploadSuccess(null);
const result = await TimetableNeoDBService.handleTimetableUpload(
event.target.files?.[0],
userNode || undefined,
workerNode?.nodeData as CCTeacherNodeProps | undefined
);
if (result.success) {
setUploadSuccess(result.message);
} else {
setUploadError(result.message);
}
} finally {
setIsUploading(false);
if (event.target) {
event.target.value = '';
}
}
};
return (
<Container maxWidth="md" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom>
Settings
</Typography>
{/* User Info Section */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
User Information
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="body1">
Email: {user?.email}
</Typography>
<Typography variant="body1">
Role: {user_role}
</Typography>
</Box>
</Paper>
{/* Timetable Upload Section - Only visible for teachers */}
{isTeacher && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Timetable Management
</Typography>
{!userNode && (
<Alert severity="info" sx={{ mb: 2 }}>
Your workspace is being set up. Some features may be limited until setup is complete.
</Alert>
)}
{uploadError && (
<Alert severity="error" sx={{ mb: 2 }}>
{uploadError}
</Alert>
)}
{uploadSuccess && (
<Alert severity="success" sx={{ mb: 2 }}>
{uploadSuccess}
</Alert>
)}
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
component="label"
disabled={isUploading || !workerNode}
color="secondary"
fullWidth
>
{isUploading ? 'Uploading...' : 'Upload Timetable'}
<input
type="file"
hidden
accept=".xlsx"
onChange={handleTimetableUpload}
disabled={isUploading}
/>
</Button>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Upload your timetable in Excel (.xlsx) format
</Typography>
</Box>
</Paper>
)}
{/* Additional settings sections can be added here */}
</Container>
);
};
export default SettingsPage;
BIN
View File
Binary file not shown.
+265
View File
@@ -0,0 +1,265 @@
import { User, AuthChangeEvent, Session } from '@supabase/supabase-js';
import { TLUserPreferences } from '@tldraw/tldraw';
import { supabase } from '../../supabaseClient';
import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig';
import { DatabaseNameService } from '../graph/databaseNameService';
export interface CCUser {
id: string;
email?: string;
user_type: string;
username: string;
display_name: string;
user_db_name: string;
school_db_name: string;
created_at?: string;
updated_at?: string;
}
export interface CCUserMetadata {
username?: string;
user_type?: string;
display_name?: string;
email?: string;
name?: string;
preferred_username?: string;
[key: string]: string | undefined;
}
export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
// Extract username from various possible sources
const username = metadata.username ||
metadata.preferred_username ||
metadata.email?.split('@')[0] ||
user.email?.split('@')[0] ||
'user';
// Extract display name from various possible sources
const displayName = metadata.display_name ||
metadata.name ||
metadata.preferred_username ||
username;
// Default to student if no user type specified
const userType = metadata.user_type || 'student';
const userDbName = DatabaseNameService.getUserPrivateDB(
userType,
username
);
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
return {
id: user.id,
email: user.email,
user_type: userType,
username: username,
display_name: displayName,
user_db_name: userDbName,
school_db_name: schoolDbName,
created_at: user.created_at,
updated_at: user.updated_at,
};
}
export type UserRole =
| 'email_teacher'
| 'email_student'
| 'cc_admin'
| 'cc_developer'
| 'super_admin';
// Login response
export interface LoginResponse {
user: CCUser | null;
accessToken: string | null;
userRole: string;
message: string | null;
}
// Session response
export interface SessionResponse {
user: CCUser | null;
accessToken: string | null;
message: string | null;
}
// Registration response
export interface RegistrationResponse extends LoginResponse {
user: CCUser;
accessToken: string | null;
userRole: UserRole;
message: string | null;
}
export interface EmailCredentials {
email: string;
password: string;
role: 'email_teacher' | 'email_student';
}
export type AuthCredentials = EmailCredentials;
export const getTldrawPreferences = (user: CCUser): TLUserPreferences => {
return {
id: user.id,
colorScheme: 'system',
};
};
class AuthService {
private static instance: AuthService;
private constructor() {}
onAuthStateChange(
callback: (event: AuthChangeEvent, session: Session | null) => void
) {
return supabase.auth.onAuthStateChange((event, session) => {
logger.info('auth-service', '🔄 Auth state changed', {
event,
hasSession: !!session,
userId: session?.user?.id,
eventType: event,
});
// Ensure we clear storage on signout
if (event === 'SIGNED_OUT') {
storageService.clearAll();
}
callback(event, session);
});
}
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
async getCurrentSession(): Promise<SessionResponse> {
try {
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) {
throw error;
}
if (!session) {
return { user: null, accessToken: null, message: 'No active session' };
}
return {
user: convertToCCUser(
session.user,
session.user.user_metadata as CCUserMetadata
),
accessToken: session.access_token,
message: 'Session retrieved',
};
} catch (error) {
logger.error('auth-service', 'Failed to get current session:', error);
throw error;
}
}
async getCurrentUser(): Promise<CCUser | null> {
try {
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null;
}
return convertToCCUser(user, user.user_metadata as CCUserMetadata);
} catch (error) {
logger.error('auth-service', 'Failed to get current user:', error);
return null;
}
}
async login({
email,
password,
role,
}: EmailCredentials): Promise<LoginResponse> {
try {
logger.info('auth-service', '🔄 Attempting login', {
email,
role,
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
logger.error('auth-service', '❌ Supabase auth error', {
error: error.message,
status: error.status,
});
throw error;
}
if (!data.session) {
logger.error('auth-service', '❌ No session after login');
throw new Error('No session after login');
}
const ccUser = convertToCCUser(
data.user,
data.user.user_metadata as CCUserMetadata
);
// Store auth session in storage
storageService.set(StorageKeys.USER_ROLE, ccUser.user_type);
storageService.set(StorageKeys.USER, ccUser);
storageService.set(StorageKeys.SUPABASE_TOKEN, data.session.access_token);
logger.info('auth-service', '✅ Login successful', {
userId: ccUser.id,
role: ccUser.user_type,
username: ccUser.username,
});
return {
user: ccUser,
accessToken: data.session.access_token,
userRole: ccUser.user_type,
message: 'Login successful',
};
} catch (error) {
logger.error('auth-service', '❌ Login failed:', error);
throw error;
}
}
async logout(): Promise<void> {
try {
logger.debug('auth-service', '🔄 Attempting logout');
const { error } = await supabase.auth.signOut({ scope: 'local' });
if (error) {
logger.error('auth-service', '❌ Logout failed:', error);
throw error;
}
// Clear all stored data
storageService.clearAll();
// Force a refresh of the auth state
await supabase.auth.refreshSession();
logger.debug('auth-service', '✅ Logout successful');
} catch (error) {
logger.error('auth-service', '❌ Logout failed:', error);
throw error;
}
}
}
export const authService = AuthService.getInstance();
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
import { TLUserPreferences, TLUser } from '@tldraw/tldraw';
import { CCUser } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
// Type-safe storage keys
export enum StorageKeys {
USER = 'user',
USER_ROLE = 'user_role',
SUPABASE_TOKEN = 'supabase_token',
MS_TOKEN = 'msAccessToken',
NEO4J_USER_DB = 'neo4jUserDbName',
NEO4J_WORKER_DB = 'neo4jWorkerDbName',
USER_NODES = 'userNodes',
CALENDAR_DATA = 'calendarData',
IS_NEW_REGISTRATION = 'isNewRegistration',
TLDRAW_PREFERENCES = 'tldrawUserPreferences',
TLDRAW_FILE_PATH = 'tldrawUserFilePath',
LOCAL_SNAPSHOT = 'localSnapshot',
NODE_FILE_PATH = 'nodeFilePath',
ONENOTE_NOTEBOOK = 'oneNoteNotebook',
PRESENTATION_MODE = 'presentationMode',
TLDRAW_USER = 'tldrawUser'
}
interface StorageValueTypes {
[StorageKeys.USER]: CCUser;
[StorageKeys.USER_ROLE]: string;
[StorageKeys.SUPABASE_TOKEN]: string;
[StorageKeys.MS_TOKEN]: string;
[StorageKeys.NEO4J_USER_DB]: string;
[StorageKeys.NEO4J_WORKER_DB]: string;
[StorageKeys.USER_NODES]: any[];
[StorageKeys.CALENDAR_DATA]: any;
[StorageKeys.IS_NEW_REGISTRATION]: boolean;
[StorageKeys.TLDRAW_PREFERENCES]: TLUserPreferences;
[StorageKeys.TLDRAW_FILE_PATH]: string;
[StorageKeys.LOCAL_SNAPSHOT]: any;
[StorageKeys.NODE_FILE_PATH]: string;
[StorageKeys.ONENOTE_NOTEBOOK]: any;
[StorageKeys.PRESENTATION_MODE]: boolean;
[StorageKeys.TLDRAW_USER]: TLUser;
}
type StorageKey = keyof StorageValueTypes;
class StorageService {
private static instance: StorageService;
private constructor() {}
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
get<K extends StorageKey>(key: K): StorageValueTypes[K] | null {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
logger.error('storage-service', `Error retrieving ${key}:`, error);
return null;
}
}
set<K extends StorageKey>(key: K, value: StorageValueTypes[K]): void {
try {
const serializedValue = JSON.stringify(value);
localStorage.setItem(key, serializedValue);
logger.debug('storage-service', `Stored ${key} in localStorage`);
} catch (error) {
logger.error('storage-service', `Error storing ${key}:`, error);
}
}
remove(key: StorageKey): void {
try {
localStorage.removeItem(key);
logger.debug('storage-service', `Removed ${key} from localStorage`);
} catch (error) {
logger.error('storage-service', `Error removing ${key}:`, error);
}
}
clearAll(): void {
try {
// Only clear app-specific storage, not Supabase's internal keys
Object.values(StorageKeys).forEach(key => {
localStorage.removeItem(key);
});
logger.debug('storage-service', 'Cleared all app items from localStorage');
} catch (error) {
logger.error('storage-service', 'Error clearing storage:', error);
}
}
// Helper method to update state and storage together
setStateAndStorage<K extends StorageKey>(
setter: React.Dispatch<React.SetStateAction<StorageValueTypes[K]>>,
key: K,
value: StorageValueTypes[K]
): void {
setter(value);
this.set(key, value);
}
}
export const storageService = StorageService.getInstance();
@@ -0,0 +1,107 @@
import { supabase } from '../../../supabaseClient';
import axios from '../../../axiosConfig';
export interface StandardizedOneNoteDetails {
id: string;
displayName: string;
createdDateTime: string;
lastModifiedDateTime: string;
links: {
oneNoteClientUrl: string;
oneNoteWebUrl: string;
};
}
export async function updateUserOneNoteDetails(userId: string, oneNoteDetails: StandardizedOneNoteDetails) {
const { error } = await supabase
.from('profiles')
.update({
one_note_details: oneNoteDetails,
updated_at: new Date().toISOString()
})
.eq('id', userId);
if (error) {
console.error('Error updating OneNote details:', error);
throw error;
}
}
export async function getOneNoteNotebooks(msAccessToken: string) {
try {
const response = await axios.get(`/msgraph/onenote/get-onenote-notebooks`, {
headers: {
'Authorization': `Bearer ${msAccessToken}`,
'Content-Type': 'application/json'
},
});
return response.data;
} catch (error) {
console.error('Error getting notebooks:', error);
throw error;
}
}
export async function createOneNoteNotebook(msAccessToken: string, uid: string): Promise<StandardizedOneNoteDetails> {
if (!msAccessToken) {
throw new Error('Microsoft token not found');
}
try {
const notebooks = await getOneNoteNotebooks(msAccessToken);
const existingNotebook = notebooks.value.find((notebook: any) =>
notebook.displayName === 'Classroom Copilot'
);
if (existingNotebook) {
const standardizedNotebook = standardizeNotebookDetails(existingNotebook);
await updateUserOneNoteDetails(uid, standardizedNotebook);
return standardizedNotebook;
}
const response = await axios.post(
`/msgraph/onenote/create-onenote-notebook?notebook_name=${encodeURIComponent('Classroom Copilot')}`,
{},
{
headers: {
'Authorization': `Bearer ${msAccessToken}`,
'Content-Type': 'application/json'
},
}
);
if (response.status !== 200) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const standardizedNotebook = standardizeNotebookDetails(response.data);
await updateUserOneNoteDetails(uid, standardizedNotebook);
return standardizedNotebook;
} catch (error) {
console.error('Error creating notebook:', error);
throw error;
}
}
export async function registerOneNoteUser(msAccessToken: string, uid: string) {
try {
return await createOneNoteNotebook(msAccessToken, uid);
} catch (error) {
console.error('Error registering Microsoft user:', error);
throw error;
}
}
function standardizeNotebookDetails(notebook: any): StandardizedOneNoteDetails {
const notebookData = notebook.data || notebook;
return {
id: notebookData.id || '',
displayName: notebookData.displayName || '',
createdDateTime: notebookData.createdDateTime || '',
lastModifiedDateTime: notebookData.lastModifiedDateTime || '',
links: {
oneNoteClientUrl: notebookData.links?.oneNoteClientUrl?.href || '',
oneNoteWebUrl: notebookData.links?.oneNoteWebUrl?.href || '',
},
};
}
+86
View File
@@ -0,0 +1,86 @@
import { TLUserPreferences } from '@tldraw/tldraw';
import { supabase } from '../../supabaseClient';
import { logger } from '../../debugConfig';
import { CCUser } from './authService';
export type UserProfile = CCUser;
export interface UserProfileUpdate extends Partial<UserProfile> {
id: string; // ID is always required for updates
}
export interface UserPreferences {
tldraw?: TLUserPreferences;
theme?: 'light' | 'dark' | 'system';
notifications?: boolean;
}
export async function createUserProfile(profile: UserProfile): Promise<UserProfile | null> {
try {
const { data, error } = await supabase
.from('profiles')
.insert([profile])
.select()
.single();
if (error) {
logger.error('supabase-profile-service', '❌ Failed to create user profile', {
userId: profile.id,
error
});
throw error;
}
return data;
} catch (error) {
logger.error('supabase-profile-service', '❌ Error in createUserProfile', error);
return null;
}
}
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
try {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (error) {
logger.error('supabase-profile-service', '❌ Failed to fetch user profile', {
userId,
error
});
throw error;
}
return data;
} catch (error) {
logger.error('supabase-profile-service', '❌ Error in getUserProfile', error);
return null;
}
}
export async function updateUserProfile(update: UserProfileUpdate): Promise<UserProfile | null> {
try {
const { data, error } = await supabase
.from('profiles')
.update(update)
.eq('id', update.id)
.select()
.single();
if (error) {
logger.error('supabase-profile-service', '❌ Failed to update user profile', {
userId: update.id,
error
});
throw error;
}
return data;
} catch (error) {
logger.error('supabase-profile-service', '❌ Error in updateUserProfile', error);
return null;
}
}
+118
View File
@@ -0,0 +1,118 @@
import { supabase } from '../../supabaseClient';
import { CCUser, convertToCCUser } from '../../services/auth/authService';
import { EmailCredentials } from '../../services/auth/authService';
import { formatEmailForDatabase } from '../graph/neoDBService';
import { RegistrationResponse } from '../../services/auth/authService';
import { neoRegistrationService } from '../graph/neoRegistrationService';
import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig';
const REGISTRATION_SERVICE = 'registration-service';
export class RegistrationService {
private static instance: RegistrationService;
private constructor() {}
static getInstance(): RegistrationService {
if (!RegistrationService.instance) {
RegistrationService.instance = new RegistrationService();
}
return RegistrationService.instance;
}
async register(credentials: EmailCredentials, displayName: string): Promise<RegistrationResponse> {
try {
logger.debug(REGISTRATION_SERVICE, '🔄 Starting registration', {
email: credentials.email,
role: credentials.role,
hasDisplayName: !!displayName
});
// Generate username from email (or use another method)
const username = formatEmailForDatabase(credentials.email);
// 1. First sign up the user in auth
const { data: authData, error: signUpError } = await supabase.auth.signUp({
email: credentials.email,
password: credentials.password,
options: {
data: {
user_type: credentials.role,
username: username,
display_name: displayName
}
}
});
if (signUpError) {
logger.error(REGISTRATION_SERVICE, '❌ Supabase signup error', { error: signUpError });
throw signUpError;
}
if (!authData.user) {
logger.error(REGISTRATION_SERVICE, '❌ No user data after registration');
throw new Error('No user data after registration');
}
const ccUser: CCUser = convertToCCUser(authData.user, authData.user.user_metadata);
// 2. Update the profile with the correct user type
const { error: updateError } = await supabase
.from('profiles')
.update({
user_type: credentials.role,
username: username,
display_name: displayName
})
.eq('id', authData.user.id)
.select()
.single();
if (updateError) {
logger.error(REGISTRATION_SERVICE, '❌ Failed to update profile', updateError);
throw updateError;
}
storageService.set(StorageKeys.IS_NEW_REGISTRATION, true);
// 3. Create Neo4j nodes
try {
const userNode = await neoRegistrationService.registerNeo4JUser(
ccUser,
username, // Pass username for database operations
credentials.role
);
logger.info(REGISTRATION_SERVICE, '✅ Registration successful with Neo4j setup', {
userId: ccUser.id,
hasUserNode: !!userNode
});
return {
user: ccUser,
accessToken: authData.session?.access_token || null,
userRole: credentials.role,
message: 'Registration successful'
};
} catch (neo4jError) {
logger.warn(REGISTRATION_SERVICE, '⚠️ Neo4j setup problem', {
userId: ccUser.id,
error: neo4jError
});
// Return success even if Neo4j setup is pending
return {
user: ccUser,
accessToken: authData.session?.access_token || null,
userRole: credentials.role,
message: 'Registration successful - Neo4j setup pending'
};
}
} catch (error) {
logger.error(REGISTRATION_SERVICE, '❌ Registration failed:', error);
throw error;
}
}
}
export const registrationService = RegistrationService.getInstance();
@@ -0,0 +1,45 @@
export async function uploadCurriculum(file: File, backendUrl: string) {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${backendUrl}/database/curriculum/upload-subject-curriculum`, {
method: 'POST',
body: formData,
});
if (response.status === 200) {
const result = await response.json();
console.log(result);
alert('Upload Successful!');
} else {
alert('Upload failed!');
}
} catch (error) {
console.error('Error uploading curriculum:', error);
alert('Upload failed!');
}
}
export async function uploadSubjectCurriculum(file: File, backendUrl: string) {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${backendUrl}/database/curriculum/upload-subject-curriculum`, {
method: 'POST',
body: formData,
});
if (response.status === 200) {
const result = await response.json();
console.log(result);
alert('Upload Successful!');
} else {
alert('Upload failed!');
}
} catch (error) {
console.error('Error uploading curriculum:', error);
alert('Upload failed!');
}
}
+58
View File
@@ -0,0 +1,58 @@
import { logger } from '../../debugConfig';
export class DatabaseNameService {
static readonly CC_USERS = 'cc.users';
static readonly CC_SCHOOLS = 'cc.institutes';
static getUserPrivateDB(userType: string, username: string): string {
const dbName = `${this.CC_USERS}.${userType}.${username}`;
logger.debug('database-name-service', '📥 Generating user private DB name', {
userType,
username,
dbName
});
return dbName;
}
static getSchoolPrivateDB(schoolId: string): string {
const dbName = `${this.CC_SCHOOLS}.${schoolId}`;
logger.debug('database-name-service', '📥 Generating school private DB name', {
schoolId,
dbName
});
return dbName;
}
static getDevelopmentSchoolDB(): string {
const dbName = `${this.CC_SCHOOLS}.development.default`;
logger.debug('database-name-service', '📥 Getting default school DB name', {
dbName
});
return dbName;
}
static getContextDatabase(context: string, userType: string, username: string): string {
logger.debug('database-name-service', '📥 Resolving context database', {
context,
userType,
username
});
// For school-related contexts, use the schools database
if (['school', 'department', 'class'].includes(context)) {
logger.debug('database-name-service', '✅ Using schools database for context', {
context,
dbName: this.CC_SCHOOLS
});
return this.CC_SCHOOLS;
}
// For user-specific contexts, use their private database
const userDb = this.getUserPrivateDB(userType, username);
logger.debug('database-name-service', '✅ Using user private database for context', {
context,
dbName: userDb
});
return userDb;
}
}
+159
View File
@@ -0,0 +1,159 @@
import { Editor, createShapeId, IndexKey } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import { getShapeType, isValidNodeType, CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { AllNodeShapes, NodeShapeType, ShapeUtils } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-shapes';
import { graphState } from '../../utils/tldraw/cc-base/cc-graph/graphStateUtil';
import { NodeResponse, ConnectedNodesResponse } from '../../types/api';
import { logger } from '../../debugConfig';
export class GraphNeoDBService {
static async fetchConnectedNodesAndEdges(
unique_id: string,
db_name: string,
editor: Editor
) {
try {
logger.debug('graph-service', '📤 Fetching connected nodes', {
unique_id,
db_name
});
const response = await axios.get<ConnectedNodesResponse>(
'/database/tools/get-connected-nodes-and-edges', {
params: {
unique_id,
db_name
}
}
);
if (response.data.status === "success") {
// Make sure editor is set in graphState
graphState.setEditor(editor);
await this.processConnectedNodesResponse(response.data);
return true;
}
throw new Error('Failed to fetch connected nodes');
} catch (error) {
logger.error('graph-service', '❌ Failed to fetch connected nodes', { error });
throw error;
}
}
private static async processConnectedNodesResponse(
data: ConnectedNodesResponse
) {
try {
// Log the incoming data
logger.debug('graph-service', '📥 Processing nodes response', {
mainNode: data.main_node,
connectedNodesCount: data.connected_nodes?.length,
relationshipsCount: data.relationships?.length
});
// Create a batch of nodes to process
const nodesToProcess: NodeResponse['node_data'][] = [];
// Add connected nodes first
if (data.connected_nodes) {
data.connected_nodes.forEach(connectedNode => {
if (isValidNodeType(connectedNode.type)) {
// Convert the simplified node structure to node_data format
const nodeData = {
unique_id: connectedNode.id,
tldraw_snapshot: connectedNode.tldraw_snapshot,
name: connectedNode.label,
__primarylabel__: connectedNode.type as keyof CCNodeTypes,
created: new Date().toISOString(),
merged: new Date().toISOString(),
};
nodesToProcess.push(nodeData);
}
});
}
// Add main node last (if it exists) to ensure it's processed after connected nodes
if (data.main_node) {
nodesToProcess.push(data.main_node.node_data);
}
// Process all nodes in batch
for (const nodeData of nodesToProcess) {
await this.createOrUpdateNode(nodeData);
logger.debug('graph-service', '📝 Processed node', {
nodeId: nodeData.unique_id,
nodeType: nodeData.__primarylabel__
});
}
// After all nodes are processed, arrange them in grid
graphState.arrangeNodesInGrid();
logger.debug('graph-service', '✅ Processed nodes batch', {
processedCount: nodesToProcess.length,
totalNodes: graphState.getAllNodes().length,
nodesInState: Array.from(graphState.nodeData.keys())
});
} catch (error) {
logger.error('graph-service', '❌ Failed to process connected nodes response', { error });
throw error;
}
}
private static async createOrUpdateNode(
nodeData: NodeResponse['node_data']
) {
const uniqueId = nodeData.unique_id;
const nodeType = nodeData.__primarylabel__;
if (!isValidNodeType(nodeType)) {
logger.warn('graph-service', '⚠️ Unknown node type', { data: nodeData });
return;
}
const shapeType = getShapeType(nodeType) as NodeShapeType;
// Get the shape util for this node type
const shapeUtil = ShapeUtils[shapeType];
if (!shapeUtil) {
logger.warn('graph-service', '⚠️ No shape util found for type', { type: shapeType });
return;
}
// Get default props from the shape util's prototype
const defaultProps = shapeUtil.prototype.getDefaultProps();
// Create the shape with proper typing based on the node type
const shape = {
id: createShapeId(uniqueId),
type: shapeType,
x: 0,
y: 0,
rotation: 0,
index: 'a1' as IndexKey,
parentId: createShapeId('page:page'),
isLocked: false,
opacity: 1,
meta: {},
props: {
...defaultProps,
...nodeData,
__primarylabel__: nodeData.__primarylabel__,
unique_id: nodeData.unique_id,
tldraw_snapshot: nodeData.path as string || '',
}
};
// Add to graphState
graphState.addNode(shape as AllNodeShapes);
logger.debug('graph-service', '📝 Node processed', {
uniqueId,
nodeType,
shapeId: shape.id,
shapeType: shape.type
});
}
}
+118
View File
@@ -0,0 +1,118 @@
import { CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../../debugConfig';
export interface BaseNodeData {
unique_id: string;
path: string;
__primarylabel__: string;
[key: string]: unknown;
}
export interface CalendarNodeData extends BaseNodeData {
__primarylabel__: 'Calendar' | 'CalendarYear' | 'CalendarMonth' | 'CalendarWeek' | 'CalendarDay';
date?: string;
}
export interface WorkerNodeData extends BaseNodeData {
__primarylabel__: 'School' | 'Department' | 'Teacher' | 'UserTeacherTimetable' | 'Class' | 'TimetableLesson';
name?: string;
teacher_code?: string;
teacher_name_formal?: string;
class_code?: string;
department_code?: string;
school_name?: string;
}
export type NodeType = keyof CCNodeTypes | 'User' | 'Calendar' | 'CalendarYear' | 'CalendarMonth' | 'CalendarWeek' | 'CalendarDay' | 'Teacher' | 'UserTeacherTimetable' | 'Student' | 'Class' | 'TimetableLesson';
export function formatEmailForDatabase(email: string): string {
// Convert to lowercase and replace special characters
const sanitized = email.toLowerCase()
.replace('@', 'at')
.replace(/\./g, 'dot')
.replace(/_/g, 'underscore')
.replace(/-/g, 'dash');
// Add prefix and ensure no consecutive dashes
return `${sanitized}`;
}
export function generateNodeTitle(nodeData: BaseNodeData): string {
try {
const calendarData = nodeData as CalendarNodeData;
const workerData = nodeData as WorkerNodeData;
switch (nodeData.__primarylabel__ as NodeType) {
// Calendar nodes
case 'Calendar':
return 'Calendar';
case 'CalendarYear':
if (!calendarData.date) return 'Unknown Year';
return `Year ${new Date(calendarData.date).getFullYear()}`;
case 'CalendarMonth':
if (!calendarData.date) return 'Unknown Month';
return new Date(calendarData.date).toLocaleString('default', { month: 'long' });
case 'CalendarWeek':
if (!calendarData.date) return 'Unknown Week';
return `Week ${new Date(calendarData.date).getDate()}`;
case 'CalendarDay':
if (!calendarData.date) return 'Unknown Day';
return new Date(calendarData.date).toLocaleDateString();
// Worker/School nodes
case 'School':
return workerData.school_name || 'School';
case 'Department':
return workerData.department_code || 'Department';
case 'Teacher':
return workerData.teacher_name_formal || workerData.teacher_code || 'Teacher';
case 'UserTeacherTimetable':
return 'Timetable';
case 'Class':
return workerData.class_code || 'Class';
case 'TimetableLesson':
return 'Lesson';
default:
logger.warn('neo4j-service', `⚠️ Unknown node type for title generation: ${nodeData.__primarylabel__}`);
return 'Unknown Node';
}
} catch (error) {
logger.error('neo4j-service', '❌ Failed to generate node title', { error, nodeData });
return 'Error: Invalid Node Data';
}
}
export function getMonthFromWeek(weekDate: string): string {
// Get the month that contains the most days of this week
const weekStart = new Date(weekDate);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
// If week spans two months, use the month that contains more days of the week
if (weekStart.getMonth() !== weekEnd.getMonth()) {
const daysInFirstMonth = new Date(weekStart.getFullYear(), weekStart.getMonth() + 1, 0).getDate() - weekStart.getDate() + 1;
const daysInSecondMonth = 7 - daysInFirstMonth;
return daysInFirstMonth >= daysInSecondMonth ?
weekStart.toLocaleString('default', { month: 'long' }) :
weekEnd.toLocaleString('default', { month: 'long' });
}
return weekStart.toLocaleString('default', { month: 'long' });
}
export function getDatabaseName(path: string, defaultSchoolUuid = 'kevlarai'): string {
// If the path starts with /node_filesystem/users/, it's in a user database
if (path.startsWith('/node_filesystem/users/')) {
const parts = path.split('/');
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
return parts[3];
}
// For school/worker nodes, extract from the path or use default
if (path.includes('/schools/')) {
return `cc.institutes.${defaultSchoolUuid}`;
}
// Default to user database if we can't determine
return path.split('/')[3];
}
@@ -0,0 +1,155 @@
import { supabase } from '../../supabaseClient';
import { CCUser } from '../auth/authService';
import { CCSchoolNodeProps, CCUserNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { storageService, StorageKeys } from '../auth/localStorageService';
import axiosInstance from '../../axiosConfig';
import { logger } from '../../debugConfig';
// Dev configuration - only hardcoded value we need
const DEV_SCHOOL_UUID = 'kevlarai';
class NeoRegistrationService {
private static instance: NeoRegistrationService;
private constructor() {}
static getInstance(): NeoRegistrationService {
if (!NeoRegistrationService.instance) {
NeoRegistrationService.instance = new NeoRegistrationService();
}
return NeoRegistrationService.instance;
}
async registerNeo4JUser(
user: CCUser,
username: string,
role: string
): Promise<CCUserNodeProps> {
try {
// For teachers and students, fetch school node first
let schoolNode = null;
if (role.includes('teacher') || role.includes('student')) {
schoolNode = await this.fetchSchoolNode(DEV_SCHOOL_UUID);
if (!schoolNode) {
throw new Error('Failed to fetch required school node');
}
}
// Create FormData with proper headers
const formData = new FormData();
// Required fields
formData.append('user_id', user.id);
formData.append('user_type', role);
formData.append('user_name', username);
formData.append('user_email', user.email || '');
// Add school data if we have a school node
if (schoolNode) {
formData.append('school_uuid', schoolNode.school_uuid);
formData.append('school_name', schoolNode.school_name);
formData.append('school_website', schoolNode.school_website);
formData.append('school_tldraw_snapshot', schoolNode.tldraw_snapshot);
// Add worker data based on role
const workerData = role.includes('teacher') ? {
teacher_code: username,
teacher_name_formal: username,
teacher_email: user.email,
} : {
student_code: username,
student_name_formal: username,
student_email: user.email,
};
formData.append('worker_data', JSON.stringify(workerData));
}
// Debug log the form data
logger.debug('neo4j-service', '🔄 Sending form data', {
userId: user.id,
userType: role,
userName: username,
userEmail: user.email,
schoolNode: schoolNode ? {
uuid: schoolNode.school_uuid,
name: schoolNode.school_name
} : null
});
const response = await axiosInstance.post('/database/entity/create-user', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data.status !== 'success') {
throw new Error(`Failed to create user: ${JSON.stringify(response.data)}`);
}
const userNode = response.data.data.user_node;
const workerNode = response.data.data.worker_node;
// Store calendar data if needed
if (response.data.data.calendar_nodes) {
logger.debug('neo4j-service', '🔄 Storing calendar data', {
calendarNodes: response.data.data.calendar_nodes
});
storageService.set(StorageKeys.CALENDAR_DATA, response.data.data.calendar_nodes);
}
// Update user node with worker data
userNode.worker_node_data = JSON.stringify(workerNode);
await this.updateUserNeo4jDetails(user.id, userNode);
logger.info('neo4j-service', '✅ Neo4j user registration successful', {
userId: user.id,
nodeId: userNode.unique_id,
hasCalendar: !!response.data.data.calendar_nodes
});
return userNode;
} catch (error) {
logger.error('neo4j-service', '❌ Neo4j user registration failed', error);
throw error;
}
}
async updateUserNeo4jDetails(userId: string, userNode: CCUserNodeProps) {
const { error } = await supabase
.from('profiles')
.update({
metadata: {
...userNode
},
updated_at: new Date().toISOString()
})
.eq('id', userId);
if (error) {
logger.error('neo4j-service', '❌ Failed to update Neo4j details:', error);
throw error;
}
}
async fetchSchoolNode(schoolUuid: string): Promise<CCSchoolNodeProps> {
logger.debug('neo4j-service', '🔄 Fetching school node', { schoolUuid });
try {
const response = await axiosInstance.get(`/database/tools/get-school-node?school_uuid=${schoolUuid}`);
if (response.data?.status === 'success' && response.data.school_node) {
logger.info('neo4j-service', '✅ School node fetched successfully');
return response.data.school_node;
}
throw new Error('Failed to fetch school node: ' + JSON.stringify(response.data));
} catch (error) {
logger.error('neo4j-service', '❌ Failed to fetch school node:', error);
throw error;
}
}
}
export const neoRegistrationService = NeoRegistrationService.getInstance();
+78
View File
@@ -0,0 +1,78 @@
import { NavigationNode } from '../../types/navigation';
import { getShapeType, CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { logger } from '../../debugConfig';
import { NodeData } from '../../types/graph-shape';
export class NeoShapeService {
private static readonly DATE_TIME_FIELDS = [
'merged', 'created', 'start_date', 'end_date', 'start_time', 'end_time'
] as const;
private static processDateTimeFields(data: Record<string, unknown>): Record<string, unknown> {
const processed = { ...data };
for (const key of Object.keys(processed)) {
if (this.DATE_TIME_FIELDS.includes(key as typeof this.DATE_TIME_FIELDS[number]) &&
processed[key] &&
typeof processed[key] === 'object') {
processed[key] = processed[key].toString();
}
}
return processed;
}
static getShapeConfig(node: NavigationNode, nodeData: NodeData, centerX: number, centerY: number) {
try {
// Get the shape type based on the node type
const shapeType = getShapeType(node.type as keyof CCNodeTypes);
// Get theme colors based on the node type
const theme = getThemeFromLabel(node.type);
// Default dimensions
const width = 500;
const height = 350;
// Process the node data
const processedProps = {
...this.processDateTimeFields(nodeData),
title: nodeData.title || node.label,
w: width,
h: height,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
logger.debug('neo-shape-service', '📄 Created shape configuration', {
nodeId: node.id,
shapeType,
theme,
props: processedProps
});
return {
type: shapeType,
x: centerX - (width / 2),
y: centerY - (height / 2),
props: processedProps
};
} catch (error) {
logger.error('neo-shape-service', '❌ Failed to create shape configuration', {
nodeId: node.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import axiosInstance from '../../axiosConfig';
import { CCSchoolNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../../debugConfig';
import { AxiosError } from 'axios';
interface CreateSchoolResponse {
status: string;
message: string;
}
export class SchoolNeoDBService {
static async createSchools(
): Promise<CreateSchoolResponse> {
logger.warn('school-service', '📤 Creating schools using default config.yaml');
try {
const response = await axiosInstance.post(
'/database/entity/create-schools',
{},
{
headers: {
'Content-Type': 'application/json'
}
}
);
if (response.data.status === 'success' || response.data.status === 'Accepted') {
logger.info('school-service', '✅ Schools successfully');
return {
status: 'success',
message: 'Schools created successfully'
};
}
throw new Error(response.data.message || 'Creation failed');
} catch (err: unknown) {
const error = err as AxiosError;
logger.error('school-service', '❌ Failed to create school', {
error: error.message,
details: error.response?.data
});
throw error;
}
}
static async getSchoolNode(schoolDbName: string): Promise<CCSchoolNodeProps | null> {
logger.debug('school-service', '🔄 Fetching school node', { schoolDbName });
try {
const response = await axiosInstance.get(`/database/tools/get-default-node/school?db_name=${schoolDbName}`);
if (response.data?.status === 'success' && response.data.node) {
logger.info('school-service', '✅ School node fetched successfully');
return response.data.node;
}
logger.warn('school-service', '⚠️ No school node found');
return null;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 404) {
logger.warn('school-service', '⚠️ School node not found (404)', { schoolDbName });
return null;
}
logger.error('school-service', '❌ Failed to fetch school node:', error);
throw error;
}
}
}
+256
View File
@@ -0,0 +1,256 @@
import axios from '../../axiosConfig';
import { CCTeacherNodeProps, CCUserNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../../debugConfig';
import { AxiosError } from 'axios';
interface UploadTimetableResponse {
status: string;
message: string;
}
interface UploadResult {
success: boolean;
message: string;
}
export interface TeacherTimetableEvent {
id: string;
title: string;
start: string;
end: string;
extendedProps: {
subjectClass: string;
color: string;
periodCode: string;
tldraw_snapshot?: string;
};
}
export class TimetableNeoDBService {
static async uploadWorkerTimetable(
file: File,
userNode: CCUserNodeProps,
workerNode: CCTeacherNodeProps
): Promise<UploadTimetableResponse> {
logger.debug('timetable-service', '📤 Uploading timetable', {
fileName: file.name,
schoolDbName: workerNode.school_db_name,
userDbName: workerNode.user_db_name,
teacherCode: workerNode.teacher_code
});
const formData = new FormData();
formData.append('file', file);
formData.append('user_node', JSON.stringify({
unique_id: userNode.unique_id,
user_id: userNode.user_id,
user_type: userNode.user_type,
user_name: userNode.user_name,
user_email: userNode.user_email,
tldraw_snapshot: userNode.tldraw_snapshot,
worker_node_data: userNode.worker_node_data
}));
formData.append('worker_node', JSON.stringify({
unique_id: workerNode.unique_id,
teacher_code: workerNode.teacher_code,
teacher_name_formal: workerNode.teacher_name_formal,
teacher_email: workerNode.teacher_email,
tldraw_snapshot: workerNode.tldraw_snapshot,
worker_db_name: workerNode.school_db_name,
user_db_name: workerNode.user_db_name
}));
try {
const response = await axios.post(
'/database/timetables/upload-worker-timetable',
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
if (response.data.status === 'success' || response.data.status === 'Accepted') {
logger.info('timetable-service', '✅ Timetable upload successful');
return {
status: 'success',
message: 'Timetable uploaded successfully'
};
}
throw new Error(response.data.message || 'Upload failed');
} catch (err: unknown) {
const error = err as AxiosError;
logger.error('timetable-service', '❌ Failed to upload timetable', {
error: error.message,
details: error.response?.data
});
throw error;
}
}
static async fetchTeacherTimetableEvents(
unique_id: string,
school_db_name: string
): Promise<TeacherTimetableEvent[]> {
try {
logger.debug('timetable-service', '📤 Fetching timetable events', {
unique_id,
school_db_name
});
const response = await axios.get('/calendar/get_teacher_timetable_events', {
params: {
unique_id,
school_db_name
}
});
logger.debug('timetable-service', '📥 Received response', {
status: response.status,
data: response.data
});
if (response.data.status === "success") {
return response.data.events;
}
throw new Error(response.data.message || 'Failed to fetch events');
} catch (error) {
if (error instanceof AxiosError) {
logger.error('timetable-service', '❌ Failed to fetch timetable events', {
status: error.response?.status,
data: error.response?.data,
message: error.message
});
} else {
logger.error('timetable-service', '❌ Failed to fetch timetable events', { error });
}
throw error;
}
}
static lightenColor(color: string, amount: number): string {
color = color.replace(/^#/, '');
const num = parseInt(color, 16);
const r = Math.min(255, (num >> 16) + amount);
const g = Math.min(255, ((num >> 8) & 0x00FF) + amount);
const b = Math.min(255, (num & 0x0000FF) + amount);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
static getContrastColor(hexColor: string): string {
hexColor = hexColor.replace(/^#/, '');
const r = parseInt(hexColor.slice(0, 2), 16);
const g = parseInt(hexColor.slice(2, 4), 16);
const b = parseInt(hexColor.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.7 ? '#000000' : '#FFFFFF';
}
static getEventRange(events: TeacherTimetableEvent[]) {
if (events.length === 0) {
return { start: null, end: null };
}
let start = new Date(events[0].start);
let end = new Date(events[0].end);
events.forEach(event => {
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
if (eventStart < start) {
start = eventStart;
}
if (eventEnd > end) {
end = eventEnd;
}
});
start.setDate(1);
end.setMonth(end.getMonth() + 1, 0);
return { start, end };
}
static getSubjectClassColor(subjectClass: string): string {
let hash = 0;
for (let i = 0; i < subjectClass.length; i++) {
hash = subjectClass.charCodeAt(i) + ((hash << 5) - hash);
}
return `hsl(${hash % 360}, 70%, 50%)`;
}
static async handleTimetableUpload(
file: File | undefined,
userNode: CCUserNodeProps | undefined,
workerNode: CCTeacherNodeProps | undefined
): Promise<UploadResult> {
if (!file) {
return {
success: false,
message: 'No file selected'
};
}
if (!file.name.endsWith('.xlsx')) {
return {
success: false,
message: 'Please upload an Excel (.xlsx) file'
};
}
if (!userNode) {
return {
success: false,
message: 'User information not found. Please ensure you are logged in as a user.'
};
}
if (!workerNode) {
return {
success: false,
message: 'Teacher information not found. Please ensure you are logged in as a teacher.'
};
}
// Validate worker node has required fields
const requiredWorkerFields = ['unique_id', 'teacher_code', 'teacher_name_formal', 'teacher_email', 'worker_db_name', 'path'];
const requiredUserFields = ['unique_id', 'user_id', 'user_type', 'user_name', 'user_email', 'path', 'worker_node_data'];
const missingWorkerFields = requiredWorkerFields.filter(field => !(field in workerNode));
const missingUserFields = requiredUserFields.filter(field => !(field in userNode));
if (missingWorkerFields.length > 0) {
logger.error('timetable-service', '❌ Missing required teacher fields:', { missingWorkerFields });
return {
success: false,
message: `Missing required teacher information: ${missingWorkerFields.join(', ')}`
};
}
if (missingUserFields.length > 0) {
logger.error('timetable-service', '❌ Missing required user fields:', { missingUserFields });
return {
success: false,
message: `Missing required user information: ${missingUserFields.join(', ')}`
};
}
try {
const result = await this.uploadWorkerTimetable(file, userNode, workerNode);
return {
success: true,
message: result.message
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to upload timetable';
logger.error('timetable-service', '❌ Timetable upload failed:', error);
return {
success: false,
message: errorMessage
};
}
}
}
+390
View File
@@ -0,0 +1,390 @@
import axiosInstance from '../../axiosConfig';
import { formatEmailForDatabase } from './neoDBService';
import { CCUserNodeProps, CCTeacherNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { NavigationNode, NodeContext } from '../../types/navigation';
import { TLBinding, TLShapeId } from '@tldraw/tldraw';
import { logger } from '../../debugConfig';
import { useNavigationStore } from '../../stores/navigationStore';
import { DatabaseNameService } from './databaseNameService';
// Dev configuration - only hardcoded value we need
const DEV_SCHOOL_UUID = 'kevlarai';
interface ShapeState {
parentId: TLShapeId | null;
isPageChild: boolean | null;
hasChildren: boolean | null;
bindings: TLBinding[] | null;
}
interface NodeResponse {
status: string;
nodes: {
userNode: CCUserNodeProps;
calendarNode: CCCalendarNodeProps;
teacherNode: CCTeacherNodeProps;
timetableNode: CCUserTeacherTimetableNodeProps;
};
}
interface NodeDataResponse {
__primarylabel__: string;
unique_id: string;
tldraw_snapshot: string;
created: string;
merged: string;
state: ShapeState | null;
defaultComponent: boolean | null;
user_name?: string;
user_email?: string;
user_type?: string;
user_id?: string;
worker_node_data?: string;
[key: string]: string | number | boolean | null | ShapeState | undefined;
}
interface DefaultNodeResponse {
status: string;
node: {
id: string;
tldraw_snapshot: string;
type: string;
label: string;
data: NodeDataResponse;
};
}
export interface ProcessedUserNodes {
privateUserNode: CCUserNodeProps;
connectedNodes: {
calendar?: CCCalendarNodeProps;
teacher?: CCTeacherNodeProps;
timetable?: CCUserTeacherTimetableNodeProps;
};
}
export interface CalendarStructureResponse {
status: string;
data: {
currentDay: string;
days: Record<string, {
id: string;
date: string;
title: string;
}>;
weeks: Record<string, {
id: string;
title: string;
days: { id: string }[];
startDate: string;
endDate: string;
}>;
months: Record<string, {
id: string;
title: string;
days: { id: string }[];
weeks: { id: string }[];
year: string;
month: string;
}>;
years: {
id: string;
title: string;
months: { id: string }[];
year: string;
}[];
};
}
export interface WorkerStructureResponse {
status: string;
data: {
timetables: Record<string, Array<{
id: string;
title: string;
type: string;
startTime: string;
endTime: string;
}>>;
classes: Record<string, Array<{
id: string;
title: string;
type: string;
}>>;
lessons: Record<string, Array<{
id: string;
title: string;
type: string;
}>>;
journals: Record<string, Array<{
id: string;
title: string;
}>>;
planners: Record<string, Array<{
id: string;
title: string;
}>>;
};
}
export class UserNeoDBService {
static async fetchUserNodesData(
email: string,
userDbName?: string,
workerDbName?: string
): Promise<ProcessedUserNodes | null> {
try {
if (!userDbName) {
logger.error('neo4j-service', '❌ Attempted to fetch nodes without database name');
return null;
}
const formattedEmail = formatEmailForDatabase(email);
const uniqueId = `User_${formattedEmail}`;
logger.debug('neo4j-service', '🔄 Fetching user nodes data', {
email,
formattedEmail,
userDbName,
workerDbName,
uniqueId
});
// First get the user node from profile context
const userNode = await this.getDefaultNode('profile', userDbName);
if (!userNode || !userNode.data) {
throw new Error('Failed to fetch user node or node data missing');
}
logger.debug('neo4j-service', '✅ Found user node', {
nodeId: userNode.id,
type: userNode.type,
hasData: !!userNode.data,
userDbName,
workerDbName
});
// Initialize result structure
const processedNodes: ProcessedUserNodes = {
privateUserNode: {
...userNode.data,
__primarylabel__: 'User' as const,
title: userNode.data.user_email || 'User',
w: 200,
h: 200,
headerColor: '#3e6589',
backgroundColor: '#f0f0f0',
isLocked: false
} as CCUserNodeProps,
connectedNodes: {}
};
try {
// Get calendar node from calendar context
const calendarNode = await this.getDefaultNode('calendar', userDbName);
if (calendarNode?.data) {
processedNodes.connectedNodes.calendar = {
...calendarNode.data,
__primarylabel__: 'Calendar' as const,
title: calendarNode.data.calendar_name || 'Calendar',
w: 200,
h: 200,
headerColor: '#3e6589',
backgroundColor: '#f0f0f0',
isLocked: false
} as CCCalendarNodeProps;
logger.debug('neo4j-service', '✅ Found calendar node', {
nodeId: calendarNode.id,
tldraw_snapshot: calendarNode.data.tldraw_snapshot
});
} else {
logger.debug('neo4j-service', '️ No calendar node found');
}
} catch (error) {
logger.warn('neo4j-service', '⚠️ Failed to fetch calendar node:', error);
// Continue without calendar node
}
// Get teacher node from teaching context if worker database is available
if (workerDbName) {
try {
const teacherNode = await this.getDefaultNode('teaching', userDbName);
if (teacherNode?.data) {
processedNodes.connectedNodes.teacher = {
...teacherNode.data,
__primarylabel__: 'Teacher' as const,
title: teacherNode.data.teacher_name_formal || 'Teacher',
w: 200,
h: 200,
headerColor: '#3e6589',
backgroundColor: '#f0f0f0',
isLocked: false,
user_db_name: userDbName,
school_db_name: workerDbName
} as CCTeacherNodeProps;
logger.debug('neo4j-service', '✅ Found teacher node', {
nodeId: teacherNode.id,
tldraw_snapshot: teacherNode.data.tldraw_snapshot,
userDbName,
workerDbName
});
} else {
logger.debug('neo4j-service', '️ No teacher node found');
}
} catch (error) {
logger.warn('neo4j-service', '⚠️ Failed to fetch teacher node:', error);
// Continue without teacher node
}
}
logger.debug('neo4j-service', '✅ Processed all user nodes', {
hasUserNode: !!processedNodes.privateUserNode,
hasCalendar: !!processedNodes.connectedNodes.calendar,
hasTeacher: !!processedNodes.connectedNodes.teacher,
teacherData: processedNodes.connectedNodes.teacher ? {
unique_id: processedNodes.connectedNodes.teacher.unique_id,
school_db_name: processedNodes.connectedNodes.teacher.school_db_name,
tldraw_snapshot: processedNodes.connectedNodes.teacher.tldraw_snapshot
} : null
});
return processedNodes;
} catch (error: unknown) {
if (error instanceof Error) {
logger.error('neo4j-service', '❌ Failed to fetch user nodes:', error.message);
} else {
logger.error('neo4j-service', '❌ Failed to fetch user nodes:', String(error));
}
throw error;
}
}
static getUserDatabaseName(userType: string, username: string): string {
return DatabaseNameService.getUserPrivateDB(userType, username);
}
static getSchoolDatabaseName(schoolId: string): string {
return DatabaseNameService.getSchoolPrivateDB(schoolId);
}
static getDefaultSchoolDatabaseName(): string {
return DatabaseNameService.getDevelopmentSchoolDB();
}
static async fetchNodeData(nodeId: string, dbName: string): Promise<{ node_type: string; node_data: NodeResponse['nodes']['userNode'] } | null> {
try {
logger.debug('neo4j-service', '🔄 Fetching node data', { nodeId, dbName });
const response = await axiosInstance.get<{
status: string;
node: {
node_type: string;
node_data: NodeResponse['nodes']['userNode'];
};
}>('/database/tools/get-node', {
params: {
unique_id: nodeId,
db_name: dbName
}
});
if (response.data?.status === 'success' && response.data.node) {
return response.data.node;
}
return null;
} catch (error) {
logger.error('neo4j-service', '❌ Failed to fetch node data:', error);
throw error;
}
}
static getNodeDatabaseName(node: NavigationNode): string {
// If the node path starts with /node_filesystem/users/, it's in a user database
if (node.tldraw_snapshot.startsWith('/node_filesystem/users/')) {
const parts = node.tldraw_snapshot.split('/');
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
return parts[3];
}
// For school/worker nodes, extract from the path or use a default
if (node.tldraw_snapshot.includes('/schools/')) {
return `cc.institutes.${DEV_SCHOOL_UUID}`;
}
// Default to user database if we can't determine
return node.tldraw_snapshot.split('/')[3];
}
static async getDefaultNode(context: NodeContext, dbName: string): Promise<NavigationNode | null> {
try {
logger.debug('neo4j-service', '🔄 Fetching default node', { context, dbName });
// For overview context, we need to extract the base context from the current navigation state
const params: Record<string, string> = { db_name: dbName };
if (context === 'overview') {
// Get the current base context from the navigation store
const navigationStore = useNavigationStore.getState();
params.base_context = navigationStore.context.base;
}
const response = await axiosInstance.get<DefaultNodeResponse>(
`/database/tools/get-default-node/${context}`,
{ params }
);
if (response.data?.status === 'success' && response.data.node) {
return {
id: response.data.node.id,
tldraw_snapshot: response.data.node.tldraw_snapshot,
type: response.data.node.type,
label: response.data.node.label,
data: response.data.node.data
};
}
return null;
} catch (error) {
logger.error('neo4j-service', '❌ Failed to fetch default node:', error);
throw error;
}
}
static async fetchCalendarStructure(dbName: string): Promise<CalendarStructureResponse['data']> {
try {
logger.debug('navigation', '🔄 Fetching calendar structure', { dbName });
const response = await axiosInstance.get<CalendarStructureResponse>(
`/database/calendar-structure/get-calendar-structure?db_name=${dbName}`
);
if (response.data.status === 'success') {
logger.info('navigation', '✅ Calendar structure fetched successfully');
return response.data.data;
}
throw new Error('Failed to fetch calendar structure');
} catch (error) {
logger.error('navigation', '❌ Failed to fetch calendar structure:', error);
throw error;
}
}
static async fetchWorkerStructure(dbName: string): Promise<WorkerStructureResponse['data']> {
try {
logger.debug('navigation', '🔄 Fetching worker structure', { dbName });
const response = await axiosInstance.get<WorkerStructureResponse>(
`/database/worker-structure/get-worker-structure?db_name=${dbName}`
);
if (response.data.status === 'success') {
logger.info('navigation', '✅ Worker structure fetched successfully');
return response.data.data;
}
throw new Error('Failed to fetch worker structure');
} catch (error) {
logger.error('navigation', '❌ Failed to fetch worker structure:', error);
throw error;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { logger } from '../debugConfig';
import Modal from 'react-modal';
let isInitialized = false;
export const initializeApp = () => {
if (isInitialized) {
return;
}
logger.debug('app', '🚀 App initializing', {
isDevMode: import.meta.env.VITE_DEV === 'true',
environment: import.meta.env.MODE,
appName: import.meta.env.VITE_APP_NAME
});
// Set the app element for react-modal
Modal.setAppElement('#root');
isInitialized = true;
};
export const resetInitialization = () => {
isInitialized = false;
};
+11
View File
@@ -0,0 +1,11 @@
import axios from '../../axiosConfig';
export const sendPrompt = async (data: { model: string, prompt: string }) => {
const response = await axios.post('/llm/ollama_text_prompt', data);
return response.data;
};
export const sendVisionPrompt = async (data: { model: string, imagePath: string, prompt: string }) => {
const response = await axios.post('/llm/ollama_vision_prompt', data);
return response.data;
};
+146
View File
@@ -0,0 +1,146 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
// Define custom theme options
const themeOptions: ThemeOptions = {
palette: {
primary: {
main: '#1976d2',
light: '#42a5f5',
dark: '#1565c0',
contrastText: '#ffffff',
},
secondary: {
main: '#dc004e',
light: '#ff4081',
dark: '#c51162',
contrastText: '#ffffff',
},
error: {
main: '#f44336',
light: '#e57373',
dark: '#d32f2f',
},
warning: {
main: '#ff9800',
light: '#ffb74d',
dark: '#f57c00',
},
info: {
main: '#2196f3',
light: '#64b5f6',
dark: '#1976d2',
},
success: {
main: '#4caf50',
light: '#81c784',
dark: '#388e3c',
},
background: {
default: '#f5f5f5',
paper: '#ffffff',
},
text: {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.6)',
disabled: 'rgba(0, 0, 0, 0.38)',
},
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
h1: {
fontSize: '2.5rem',
fontWeight: 500,
},
h2: {
fontSize: '2rem',
fontWeight: 500,
},
h3: {
fontSize: '1.75rem',
fontWeight: 500,
},
h4: {
fontSize: '1.5rem',
fontWeight: 500,
},
h5: {
fontSize: '1.25rem',
fontWeight: 500,
},
h6: {
fontSize: '1rem',
fontWeight: 500,
},
body1: {
fontSize: '1rem',
lineHeight: 1.5,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.43,
},
},
shape: {
borderRadius: 4,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: '4px',
padding: '6px 16px',
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0px 2px 4px -1px rgba(0,0,0,0.2)',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '4px',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '8px',
boxShadow: '0px 2px 4px -1px rgba(0,0,0,0.1)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: '0px 1px 3px rgba(0,0,0,0.12)',
},
},
},
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920,
},
},
};
export const theme = createTheme(themeOptions);
+82
View File
@@ -0,0 +1,82 @@
import {
TLStore,
createTLStore,
TLEditorSnapshot,
loadSnapshot,
TLAnyShapeUtilConstructor,
TLAnyBindingUtilConstructor,
TLSchema
} from '@tldraw/tldraw';
import { LoadingState } from './snapshotService';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { logger } from '../../debugConfig';
import { customSchema } from '../../utils/tldraw/schemas';
interface LocalStoreConfig {
shapeUtils?: TLAnyShapeUtilConstructor[];
bindingUtils?: TLAnyBindingUtilConstructor[];
schema?: TLSchema;
}
class LocalStoreService {
private store: TLStore | null = null;
private static instance: LocalStoreService;
public static getInstance(): LocalStoreService {
if (!LocalStoreService.instance) {
LocalStoreService.instance = new LocalStoreService();
}
return LocalStoreService.instance;
}
public getStore(config?: LocalStoreConfig): TLStore {
if (!this.store) {
logger.debug('system', '🔄 Creating new TLStore');
this.store = createTLStore({
shapeUtils: config?.shapeUtils || allShapeUtils,
bindingUtils: config?.bindingUtils || allBindingUtils,
schema: config?.schema || customSchema,
});
}
return this.store;
}
public async loadSnapshot(
snapshot: Partial<TLEditorSnapshot>,
setLoadingState: (state: LoadingState) => void
): Promise<void> {
try {
if (!this.store) {
throw new Error('Store not initialized');
}
logger.debug('system', '📥 Loading snapshot into store');
loadSnapshot(this.store, snapshot);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('system', '❌ Failed to load snapshot:', error);
if (this.store) {
this.store.clear();
}
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load snapshot'
});
}
}
public clearStore(): void {
logger.debug('system', '🧹 Clearing store');
if (this.store) {
this.store.clear();
}
this.store = null;
}
public isStoreReady(): boolean {
return !!this.store;
}
}
export const localStoreService = LocalStoreService.getInstance();
+213
View File
@@ -0,0 +1,213 @@
import { Editor, TLShape, createShapeId } from '@tldraw/tldraw';
import { logger } from '../../debugConfig';
import { NavigationNode } from '../../types/navigation';
import { NeoShapeService } from '../graph/neoShapeService';
import { NodeData } from '../../types/graph-shape';
export class NodeCanvasService {
private static readonly CANVAS_PADDING = 100;
private static readonly ANIMATION_DURATION = 500;
private static currentAnimation: number | null = null;
private static findAllNodeShapes(editor: Editor, nodeId: string): TLShape[] {
const shapes = editor.getCurrentPageShapes();
const exactShapeId = `shape:${nodeId}`;
// Filter shapes with exact ID match only
return shapes.filter((shape: TLShape) => {
const shapeId = shape.id.toString();
return shapeId === exactShapeId || shapeId === nodeId;
});
}
private static handleMultipleNodeInstances(editor: Editor, nodeId: string, shapes: TLShape[]): TLShape | undefined {
if (shapes.length > 1) {
logger.warn('node-canvas', '⚠️ Multiple instances of node found', {
nodeId,
count: shapes.length,
shapes: shapes.map(s => s.id)
});
// Return the first instance but log a warning for the user
return shapes[0];
}
return shapes[0];
}
private static cancelCurrentAnimation(): void {
if (this.currentAnimation !== null) {
cancelAnimationFrame(this.currentAnimation);
this.currentAnimation = null;
}
}
private static animateViewToShape(editor: Editor, shape: TLShape): void {
// Cancel any existing animation
this.cancelCurrentAnimation();
const bounds = editor.getShapePageBounds(shape);
if (!bounds) {
logger.warn('node-canvas', '⚠️ Could not get shape bounds', { shapeId: shape.id });
return;
}
// Get the current viewport and camera state
const viewportBounds = editor.getViewportPageBounds();
const camera = editor.getCamera();
const currentPage = editor.getCurrentPage();
// Calculate the center point of the shape in page coordinates
const shapeCenterX = bounds.x + bounds.w / 2;
const shapeCenterY = bounds.y + bounds.h / 2;
// Calculate where the shape currently appears in the viewport
const currentViewportCenterX = viewportBounds.x + viewportBounds.w / 2;
const currentViewportCenterY = viewportBounds.y + viewportBounds.h / 2;
// Check if the shape is already reasonably centered
const tolerance = 50; // pixels
const isAlreadyCentered =
Math.abs(shapeCenterX - currentViewportCenterX) < tolerance &&
Math.abs(shapeCenterY - currentViewportCenterY) < tolerance;
// Log the current state for debugging
logger.debug('node-canvas', '📊 Current canvas state', {
page: {
id: currentPage.id,
name: currentPage.name,
shapes: editor.getCurrentPageShapes().length
},
camera: {
current: camera,
viewport: viewportBounds
},
shape: {
id: shape.id,
bounds,
center: { x: shapeCenterX, y: shapeCenterY },
currentViewportCenter: { x: currentViewportCenterX, y: currentViewportCenterY },
isAlreadyCentered
}
});
// If the shape is already centered, don't animate
if (isAlreadyCentered) {
logger.debug('node-canvas', '✨ Shape is already centered, skipping animation');
return;
}
// Calculate the target camera position to center the shape
const targetX = camera.x + (currentViewportCenterX - shapeCenterX);
const targetY = camera.y + (currentViewportCenterY - shapeCenterY);
const startX = camera.x;
const startY = camera.y;
// Force the camera to maintain its current zoom level
const currentZoom = camera.z;
// Animate the camera position
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / this.ANIMATION_DURATION, 1);
// Use easeInOutCubic for smooth animation
const eased = progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
const x = startX + (targetX - startX) * eased;
const y = startY + (targetY - startY) * eased;
editor.setCamera({
...camera,
x,
y,
z: currentZoom // Maintain zoom level
});
if (progress < 1) {
this.currentAnimation = requestAnimationFrame(animate);
} else {
this.currentAnimation = null;
logger.debug('node-canvas', '✅ Shape centering animation complete', {
finalPosition: { x, y, z: currentZoom },
shapeCenterPoint: { x: shapeCenterX, y: shapeCenterY }
});
}
};
this.currentAnimation = requestAnimationFrame(animate);
}
static async centerCurrentNode(editor: Editor, node: NavigationNode, nodeData: NodeData): Promise<void> {
try {
// Cancel any existing animation before starting
this.cancelCurrentAnimation();
const shapes = this.findAllNodeShapes(editor, node.id);
if (shapes.length > 0) {
const existingShape = this.handleMultipleNodeInstances(editor, node.id, shapes);
if (existingShape) {
// Ensure the shape is actually on the canvas
const bounds = editor.getShapePageBounds(existingShape);
if (!bounds) {
logger.warn('node-canvas', '⚠️ Shape exists but has no bounds', {
nodeId: node.id,
shapeId: existingShape.id
});
return;
}
this.animateViewToShape(editor, existingShape);
logger.debug('node-canvas', '🎯 Centered view on existing shape', {
nodeId: node.id,
shapeBounds: bounds
});
}
} else {
// Create new shape for the node
const newShape = await this.createNodeShape(editor, node, nodeData);
if (newShape) {
this.animateViewToShape(editor, newShape);
logger.debug('node-canvas', '✨ Created and centered new shape', { nodeId: node.id });
} else {
logger.warn('node-canvas', '⚠️ Could not create or center node shape', { nodeId: node.id });
}
}
} catch (error) {
this.cancelCurrentAnimation();
logger.error('node-canvas', '❌ Failed to center node', {
nodeId: node.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
private static async createNodeShape(editor: Editor, node: NavigationNode, nodeData: NodeData): Promise<TLShape | null> {
try {
const viewportBounds = editor.getViewportPageBounds();
const centerX = viewportBounds.x + viewportBounds.w / 2;
const centerY = viewportBounds.y + viewportBounds.h / 2;
// Get shape configuration from NeoShapeService
const shapeConfig = NeoShapeService.getShapeConfig(node, nodeData, centerX, centerY);
const shapeId = createShapeId(node.id);
// Create the shape with the configuration
editor.createShape<TLShape>({
id: shapeId,
...shapeConfig
});
return editor.getShape(shapeId) || null;
} catch (error) {
logger.error('node-canvas', '❌ Failed to create node shape', {
nodeId: node.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
return null;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { TldrawOptions } from "@tldraw/tldraw";
export const multiplayerOptions: Partial<TldrawOptions> = {
actionShortcutsLocation: "swap",
adjacentShapeMargin: 10,
animationMediumMs: 320,
cameraMovingTimeoutMs: 64,
cameraSlideFriction: 0.09,
coarseDragDistanceSquared: 36,
coarseHandleRadius: 20,
coarsePointerWidth: 12,
collaboratorCheckIntervalMs: 1200,
collaboratorIdleTimeoutMs: 3000,
collaboratorInactiveTimeoutMs: 60000,
defaultSvgPadding: 32,
doubleClickDurationMs: 450,
dragDistanceSquared: 16,
edgeScrollDelay: 200,
edgeScrollDistance: 8,
edgeScrollEaseDuration: 200,
edgeScrollSpeed: 25,
flattenImageBoundsExpand: 64,
flattenImageBoundsPadding: 16,
followChaseViewportSnap: 2,
gridSteps: [
{ mid: 0.15, min: -1, step: 64 },
{ mid: 0.375, min: 0.05, step: 16 },
{ mid: 1, min: 0.15, step: 4 },
{ mid: 2.5, min: 0.7, step: 1 }
],
handleRadius: 12,
hitTestMargin: 8,
laserDelayMs: 1200,
longPressDurationMs: 500,
maxExportDelayMs: 5000,
maxFilesAtOnce: 100,
maxPages: 1,
maxPointsPerDrawShape: 500,
maxShapesPerPage: 4000,
multiClickDurationMs: 200,
temporaryAssetPreviewLifetimeMs: 180000,
textShadowLod: 0.35
}
+234
View File
@@ -0,0 +1,234 @@
import { Editor, TLStoreEventInfo, createShapeId, TLShape } from '@tldraw/tldraw'
import { logger } from '../../debugConfig'
import { CCSlideShowShape } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideShowShapeUtil'
import { CCSlideShape } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideShapeUtil'
import { CCSlideLayoutBinding } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideLayoutBindingUtil'
export class PresentationService {
private editor: Editor
private initialSlideshow: CCSlideShowShape | null = null
private cameraProxyId = createShapeId('camera-proxy')
private lastUserInteractionTime = 0
private readonly USER_INTERACTION_DEBOUNCE = 1000 // 1 second
private zoomLevels = new Map<string, number>() // Track zoom levels by shape dimensions
private isMoving = false
constructor(editor: Editor) {
this.editor = editor
logger.debug('system', '🎥 PresentationService initialized')
// Add style to hide camera proxy frame
const style = document.createElement('style')
style.setAttribute('data-camera-proxy', this.cameraProxyId)
style.textContent = `
[data-shape-id="${this.cameraProxyId}"] {
opacity: 0 !important;
pointer-events: none !important;
}
`
document.head.appendChild(style)
}
private getShapeDimensionKey(width: number, height: number): string {
return `${Math.round(width)}_${Math.round(height)}`
}
private async moveToShape(shape: CCSlideShape | CCSlideShowShape): Promise<void> {
if (this.isMoving) {
logger.debug('presentation', '⏳ Movement in progress, queueing next movement')
// Wait for current movement to complete
await new Promise(resolve => setTimeout(resolve, 100))
return this.moveToShape(shape)
}
this.isMoving = true
const bounds = this.editor.getShapePageBounds(shape.id)
if (!bounds) {
logger.warn('presentation', '⚠️ Could not get bounds for shape')
this.isMoving = false
return
}
try {
// Phase 1: Update proxy shape instantly
this.editor.updateShape({
id: this.cameraProxyId,
type: 'frame',
x: bounds.minX,
y: bounds.minY,
props: {
w: bounds.width,
h: bounds.height,
name: 'camera-proxy'
}
})
// Wait for a frame to ensure bounds are updated
await new Promise(resolve => requestAnimationFrame(resolve))
// Phase 2: Calculate and apply camera movement
const viewport = this.editor.getViewportPageBounds()
const padding = 32
const dimensionKey = this.getShapeDimensionKey(bounds.width, bounds.height)
// Get existing zoom level for this shape size or calculate new one
let targetZoom = this.zoomLevels.get(dimensionKey)
if (!targetZoom) {
targetZoom = Math.min(
(viewport.width - padding * 2) / bounds.width,
(viewport.height - padding * 2) / bounds.height
)
this.zoomLevels.set(dimensionKey, targetZoom)
logger.debug('presentation', '📏 New zoom level calculated', {
dimensions: dimensionKey,
zoom: targetZoom
})
}
// Stop any existing camera movement
this.editor.stopCameraAnimation()
// Move camera to new position
this.editor.zoomToBounds(bounds, {
animation: {
duration: 500,
easing: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
},
targetZoom,
inset: padding
})
// Wait for animation to complete
await new Promise(resolve => setTimeout(resolve, 500))
} catch (error) {
logger.error('presentation', '❌ Error during shape transition', { error })
} finally {
this.isMoving = false
}
}
startPresentationMode() {
logger.info('presentation', '🎥 Starting presentation mode')
// Reset zoom levels on start
this.zoomLevels.clear()
// Find initial slideshow to track
const slideshows = this.editor.getSortedChildIdsForParent(this.editor.getCurrentPageId())
.map(id => this.editor.getShape(id))
.filter(shape => shape?.type === 'cc-slideshow')
if (slideshows.length === 0) {
logger.warn('presentation', '⚠️ No slideshows found')
return () => {}
}
this.initialSlideshow = slideshows[0] as CCSlideShowShape
// Create camera proxy shape if it doesn't exist
if (!this.editor.getShape(this.cameraProxyId)) {
this.editor.createShape({
id: this.cameraProxyId,
type: 'frame',
x: 0,
y: 0,
props: {
w: 1,
h: 1,
name: 'camera-proxy'
}
})
}
const handleStoreChange = (event: TLStoreEventInfo) => {
// Debounce user interaction logs
if (event.source === 'user') {
const now = Date.now()
if (now - this.lastUserInteractionTime > this.USER_INTERACTION_DEBOUNCE) {
logger.debug('presentation', '📝 User interaction received')
this.lastUserInteractionTime = now
}
}
if (!event.changes.updated) return
// Only process shape updates
const shapeUpdates = Object.entries(event.changes.updated)
.filter(([, [from, to]]) =>
from.typeName === 'shape' &&
to.typeName === 'shape' &&
(from as TLShape).type === 'cc-slideshow' &&
(to as TLShape).type === 'cc-slideshow'
)
if (shapeUpdates.length === 0) return
for (const [, [from, to]] of shapeUpdates) {
const fromShape = from as TLShape
const toShape = to as TLShape
if (!this.initialSlideshow || fromShape.id !== this.initialSlideshow.id) continue
const fromShow = fromShape as CCSlideShowShape
const toShow = toShape as CCSlideShowShape
if (fromShow.props.currentSlideIndex === toShow.props.currentSlideIndex) continue
logger.info('presentation', '🔄 Moving to new slide', {
from: fromShow.props.currentSlideIndex,
to: toShow.props.currentSlideIndex
})
// Get all bindings for this slideshow, sorted by index
const bindings = this.editor
.getBindingsFromShape(toShow, 'cc-slide-layout')
.filter((b): b is CCSlideLayoutBinding => b.type === 'cc-slide-layout')
.filter(b => !b.props.placeholder)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const currentBinding = bindings[toShow.props.currentSlideIndex]
if (!currentBinding) {
logger.warn('presentation', '⚠️ Could not find binding for target slide')
continue
}
const currentSlide = this.editor.getShape(currentBinding.toId) as CCSlideShape
if (!currentSlide) {
logger.warn('presentation', '⚠️ Could not find target slide')
continue
}
void this.moveToShape(currentSlide)
}
}
// Set up store listener and get cleanup function
const storeCleanup = this.editor.store.listen(handleStoreChange)
// Return cleanup function
return () => {
logger.info('presentation', '🧹 Running presentation mode cleanup')
storeCleanup()
this.stopPresentationMode()
}
}
stopPresentationMode() {
this.zoomLevels.clear()
this.isMoving = false
if (this.editor.getShape(this.cameraProxyId)) {
this.editor.deleteShape(this.cameraProxyId)
}
// Remove the style element
const style = document.querySelector(`style[data-camera-proxy="${this.cameraProxyId}"]`)
if (style) {
style.remove()
}
}
// Public method to move to any shape (slide or slideshow)
zoomToShape(shape: CCSlideShape | CCSlideShowShape) {
void this.moveToShape(shape)
}
}
+52
View File
@@ -0,0 +1,52 @@
interface SearXNGResult {
title?: string
url?: string
content?: string
}
export interface SearchResult {
title: string;
url: string;
content?: string;
}
export class SearchService {
static async search(query: string): Promise<SearchResult[]> {
if (!query.trim()) {
return []
}
try {
const searchParams = new URLSearchParams({
q: query,
format: 'json',
language: 'en',
time_range: 'None',
safesearch: '0',
engines: 'google,bing,duckduckgo',
})
const url = `${import.meta.env.VITE_FRONTEND_SITE_URL}/searxng-api/search?${searchParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Search failed with status: ${response.status}`)
}
const data = await response.json()
return (data.results || []).map((result: SearXNGResult) => ({
title: result.title || '',
url: result.url || '',
content: result.content || ''
}))
} catch (error) {
console.error('Search error:', error)
throw error
}
}
}
+122
View File
@@ -0,0 +1,122 @@
// External imports
import { TLStore, TLEditorSnapshot, loadSnapshot, getSnapshot } from '@tldraw/tldraw';
// Local imports
import { logger } from '../../debugConfig';
import { LoadingState } from './snapshotService';
import { storageService, StorageKeys } from '../auth/localStorageService';
interface AutoSaveConfig {
checkInterval: number; // Changed to required
saveInterval: number; // Changed to required
}
const DEFAULT_CONFIG: AutoSaveConfig = {
checkInterval: 5000,
saveInterval: 30000
};
export class SharedStoreService {
private lastSaveTime: number = Date.now();
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
private config: AutoSaveConfig;
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
this.config = {
...DEFAULT_CONFIG,
...config
};
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
}
public startAutoSave(setLoadingState: (state: LoadingState) => void): void {
if (this.autoSaveInterval) {
this.stopAutoSave();
}
this.autoSaveInterval = setInterval(() => {
this.checkAndSave(setLoadingState);
}, this.config.checkInterval);
logger.debug('shared-store-service', '⏰ Auto-save started', {
checkInterval: this.config.checkInterval,
saveInterval: this.config.saveInterval
});
}
public stopAutoSave(): void {
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
this.autoSaveInterval = null;
logger.debug('shared-store-service', '⏹️ Auto-save stopped');
}
}
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
const now = Date.now();
if (now - this.lastSaveTime >= this.config.saveInterval) {
const currentSnapshot = getSnapshot(this.store);
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
await this.saveSnapshot(currentSnapshot, setLoadingState);
this.lastSaveTime = now;
} else {
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
}
}
}
public async saveSnapshot(
snapshot: Partial<TLEditorSnapshot>,
setLoadingState: (state: LoadingState) => void
): Promise<void> {
try {
storageService.set(StorageKeys.LOCAL_SNAPSHOT, snapshot);
setLoadingState({ status: 'ready', error: '' });
logger.debug('shared-store-service', '✅ Snapshot saved successfully');
} catch (error) {
logger.error('shared-store-service', '❌ Failed to save snapshot:', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to save snapshot'
});
}
}
public async loadSnapshot(
snapshot: Partial<TLEditorSnapshot>,
setLoadingState: (state: LoadingState) => void
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
loadSnapshot(this.store, snapshot);
setLoadingState({ status: 'ready', error: '' });
logger.debug('shared-store-service', '✅ Snapshot loaded successfully');
} catch (error) {
logger.error('shared-store-service', '❌ Failed to load snapshot:', error);
this.store.clear();
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load snapshot'
});
}
}
public getStore(): TLStore {
return this.store;
}
public clear(): void {
this.stopAutoSave();
this.store.clear();
logger.debug('shared-store-service', '🧹 Store cleared');
}
}
export const createSharedStore = (
store: TLStore,
config?: Partial<AutoSaveConfig>
): SharedStoreService => {
return new SharedStoreService(store, config);
};
+314
View File
@@ -0,0 +1,314 @@
// External imports
import { loadSnapshot, TLStore, getSnapshot } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService';
import { StorageKeys, storageService } from '../auth/localStorageService';
import { NavigationNode } from '../../types/navigation';
export interface LoadingState {
status: 'loading' | 'ready' | 'error';
error: string;
}
const EMPTY_NODE: NavigationNode = {
id: '',
tldraw_snapshot: '',
type: '',
label: ''
};
export class NavigationSnapshotService {
private store: TLStore;
private currentNodePath: string | null = null;
private isAutoSaveEnabled = true;
private isSaving = false;
private isLoading = false;
private pendingOperation: { save?: string; load?: string } | null = null;
private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(store: TLStore) {
this.store = store;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id
});
}
private static replaceBackslashes(input: string | undefined): string {
return input ? input.replace(/\\/g, '/') : '';
}
static async loadNodeSnapshotFromDatabase(
nodePath: string,
dbName: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
logger.info('snapshot-service', '📂 Loading file from path', {
path: nodePath,
db_name: dbName
});
const response = await axios.get(
'/database/tldraw_fs/get_tldraw_node_file', {
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
}
}
);
const snapshot = response.data;
if (snapshot && snapshot.document && snapshot.session) {
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState);
} else {
loadSnapshot(store, snapshot);
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
}
} else {
logger.error('snapshot-service', '❌ Invalid snapshot format');
setLoadingState({ status: 'error', error: 'Invalid snapshot format' });
}
} catch (error) {
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
error: error instanceof Error ? error.message : 'Unknown error'
});
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load file'
});
}
}
static async saveNodeSnapshotToDatabase(
nodePath: string,
dbName: string,
store: TLStore
): Promise<void> {
try {
logger.info('snapshot-service', '💾 Saving snapshot to database', {
path: nodePath,
db_name: dbName
});
const snapshot = getSnapshot(store);
const response = await axios.post(
'/database/tldraw_fs/set_tldraw_node_file',
snapshot,
{
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
}
}
);
if (response.data.status === 'success') {
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
} else {
throw new Error('Failed to save snapshot');
}
} catch (error) {
logger.error('snapshot-service', '❌ Failed to save snapshot', {
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
}
}
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
if (!this.currentNodePath || this.currentNodePath !== nodePath) {
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
currentPath: this.currentNodePath,
savePath: nodePath
});
return;
}
try {
this.isSaving = true;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = user.user_db_name;
logger.debug('snapshot-service', '💾 Saving snapshot', {
nodePath,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store);
logger.debug('snapshot-service', '✅ Saved navigation snapshot', {
nodePath,
storeId: this.store.id
});
} catch (error) {
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath
});
throw error;
} finally {
this.isSaving = false;
}
}
private async loadSnapshotForNode(node: NavigationNode): Promise<void> {
try {
this.isLoading = true;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = user.user_db_name;
logger.debug('snapshot-service', '📥 Loading snapshot', {
nodePath: node.tldraw_snapshot,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.tldraw_snapshot,
dbName,
this.store,
(state: LoadingState) => {
if (state.status === 'ready') {
this.currentNodePath = node.tldraw_snapshot;
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
nodePath: node.tldraw_snapshot
});
} else if (state.status === 'error') {
logger.error('snapshot-service', '❌ Error in load callback', {
error: state.error,
nodePath: node.tldraw_snapshot
});
}
}
);
} catch (error) {
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath: node.tldraw_snapshot
});
throw error;
} finally {
this.isLoading = false;
}
}
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
if (!toNode) {
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node');
return;
}
// Clear any pending debounce
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Debounce the navigation operation
return new Promise((resolve) => {
this.debounceTimeout = setTimeout(async () => {
try {
await this.executeNavigation(fromNode || EMPTY_NODE, toNode);
resolve();
} catch (error) {
logger.error('snapshot-service', '❌ Navigation failed', error);
throw error;
}
}, 100); // 100ms debounce
});
}
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
try {
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
from: fromNode.tldraw_snapshot,
to: toNode.tldraw_snapshot,
currentPath: this.currentNodePath
});
// If we're already in a navigation operation, queue this one
if (this.isSaving || this.isLoading) {
this.pendingOperation = {
save: fromNode.tldraw_snapshot || undefined,
load: toNode.tldraw_snapshot
};
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
return;
}
// Clear the store before loading new snapshot
logger.debug('snapshot-service', '🔄 Clearing store');
this.currentNodePath = null;
logger.debug('snapshot-service', '🧹 Cleared current node path');
// Load the new node's snapshot
if (toNode.tldraw_snapshot) {
await this.loadSnapshotForNode(toNode);
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
nodePath: toNode.tldraw_snapshot
});
}
// Process any pending operations
if (this.pendingOperation) {
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation);
const operation = this.pendingOperation;
this.pendingOperation = null;
await this.handleNavigationStart(
operation.save ? { ...EMPTY_NODE, tldraw_snapshot: operation.save } : null,
operation.load ? { ...EMPTY_NODE, tldraw_snapshot: operation.load } : null
);
logger.debug('snapshot-service', '✅ Completed pending operation');
}
} catch (error) {
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
error: error instanceof Error ? error.message : 'Unknown error',
fromPath: fromNode.tldraw_snapshot,
toPath: toNode.tldraw_snapshot
});
throw error;
}
}
setAutoSave(enabled: boolean): void {
this.isAutoSaveEnabled = enabled;
logger.debug('snapshot-service', '🔄 Auto-save setting changed', {
enabled
});
}
getCurrentNodePath(): string | null {
return this.currentNodePath;
}
async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath);
}
}
clearCurrentNode(): void {
this.currentNodePath = null;
this.store.clear();
logger.debug('snapshot-service', '🧹 Cleared current node and store');
}
}
+117
View File
@@ -0,0 +1,117 @@
// External imports
import {
TLAssetStore,
uniqueId,
TLAsset,
TLBookmarkAsset,
AssetRecordType,
getHashForString,
} from '@tldraw/tldraw';
import { logger } from '../../debugConfig';
export interface SyncConnectionOptions {
userId: string;
displayName: string;
color: string;
roomId?: string;
baseUrl: string;
}
export function createSyncConnectionOptions(options: SyncConnectionOptions) {
const {
userId,
displayName,
roomId = 'multiplayer',
baseUrl
} = options;
// Ensure we have valid user info
if (!userId || !displayName) {
logger.warn('sync-service', 'Missing user information', { userId, displayName });
}
// Create a unique room ID if not provided
const effectiveRoomId = roomId || `room-${uniqueId()}`;
const multiplayerAssets: TLAssetStore = {
async upload(_asset: unknown, file: File) {
const id = uniqueId();
const objectName = `${id}-${file.name}`;
const uploadPath = '/uploads';
const url = `${baseUrl}${uploadPath}/${encodeURIComponent(objectName)}`;
try {
const response = await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
});
if (!response.ok) {
const errorDetail = await response.text();
throw new Error(`Failed to upload asset: ${response.statusText} - Details: ${errorDetail}`);
}
return url;
} catch (error) {
logger.error('sync-service', 'Error during asset upload: ', error);
throw error;
}
},
resolve(asset: TLAsset) {
return asset.props.src ?? '';
}
};
logger.info('sync-service', '🔄 Creating sync connection', {
userId,
displayName,
roomId: effectiveRoomId
});
return {
uri: `${baseUrl}/connect/${effectiveRoomId}`,
assets: multiplayerAssets,
roomId: effectiveRoomId
};
}
export async function handleExternalAsset(baseUrl: string, url: string): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: '',
image: '',
favicon: '',
title: ''
},
meta: {}
};
try {
const response = await fetch(`${baseUrl}/unfurl?url=${encodeURIComponent(url)}`);
const data = await response.json();
asset.props = {
...asset.props,
...data
};
} catch (error) {
logger.error('sync-service', 'Error unfurling URL:', error);
}
return asset;
}
export function generateSharedRoomId(path: string): string {
// Create a deterministic room ID based on the path
const sanitizedPath = path.replace(/[^a-zA-Z0-9]/g, '-');
return `shared-${sanitizedPath}`;
}
@@ -0,0 +1,29 @@
import axios from '../../../axiosConfig';
interface TranscriptLine {
start: number;
duration: number;
text: string;
}
export async function getYoutubeTranscript(videoUrl: string): Promise<TranscriptLine[]> {
try {
const videoId = extractVideoId(videoUrl);
if (!videoId) {
throw new Error('Invalid YouTube URL');
}
const response = await axios.get(`/external/youtube-proxy?videoId=${videoId}`);
console.log('Got Youtube video data:', response.data);
return response.data.transcript;
} catch (error) {
console.error('Error fetching YouTube video data:', error);
throw error;
}
}
export function extractVideoId(url: string): string | null {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}
+3
View File
@@ -0,0 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom';
import { expect } from 'vitest';
+439
View File
@@ -0,0 +1,439 @@
import { create } from 'zustand';
import { UserNeoDBService } from '../services/graph/userNeoDBService';
import { logger } from '../debugConfig';
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import {
NavigationStore,
NavigationNode,
MainContext,
BaseContext,
NavigationContextState,
isProfileContext,
isInstituteContext,
getContextDatabase,
addToHistory,
navigateHistory,
getCurrentHistoryNode,
ExtendedContext,
UnifiedContextSwitch,
NodeContext
} from '../types/navigation';
const initialState: NavigationContextState = {
main: 'profile',
base: 'profile',
node: null,
history: {
nodes: [],
currentIndex: -1
}
};
function getDefaultBaseForMain(main: MainContext): BaseContext {
return main === 'profile' ? 'profile' : 'school';
}
function validateContextTransition(
current: NavigationContextState,
updates: Partial<NavigationContextState>
): NavigationContextState {
const newState = { ...current, ...updates };
// Validate main context
if (updates.main) {
newState.base = getDefaultBaseForMain(updates.main);
}
// Validate base context
if (updates.base) {
// Ensure base context matches main context
const isValid = newState.main === 'profile'
? isProfileContext(updates.base)
: isInstituteContext(updates.base);
if (!isValid) {
newState.base = getDefaultBaseForMain(newState.main);
}
}
return newState;
}
export interface NavigationActions {
// Context Navigation
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// Node Navigation
navigate: (nodeId: string, dbName: string) => Promise<void>;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// History Navigation
goBack: () => void;
goForward: () => void;
// Utility Methods
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
}
export interface NavigationState {
context: {
main: NodeContext;
base: NodeContext;
extended?: string;
node: NavigationNode;
history: {
nodes: NavigationNode[];
currentIndex: number;
};
};
// ... rest of the state interface ...
}
export const useNavigationStore = create<NavigationStore>((set, get) => ({
context: initialState,
isLoading: false,
error: null,
switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => {
try {
// Check if we have the necessary database connections
if (contextSwitch.main === 'profile' && !userDbName) {
logger.error('navigation-context', '❌ User database connection not initialized');
set({
error: 'User database connection not initialized',
isLoading: false
});
return;
}
if (contextSwitch.main === 'institute' && !workerDbName) {
logger.error('navigation-context', '❌ Worker database connection not initialized');
set({
error: 'Worker database connection not initialized',
isLoading: false
});
return;
}
logger.debug('navigation-context', '🔄 Starting context switch', {
from: {
main: get().context.main,
base: get().context.base,
extended: contextSwitch.extended,
nodeId: get().context.node?.id
},
to: {
main: contextSwitch.main,
base: contextSwitch.base,
extended: contextSwitch.extended
},
skipBaseContextLoad: contextSwitch.skipBaseContextLoad
});
set({ isLoading: true, error: null });
const currentState = get().context;
// Clear node state immediately
const clearedState: NavigationContextState = {
...currentState,
node: null
};
set({
context: clearedState,
isLoading: true
});
let newState: NavigationContextState = {
...currentState,
node: null
};
// Update main context if provided
if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main);
}
logger.debug('navigation-state', '✅ Main context updated', {
previous: currentState.main,
new: newState.main,
defaultBase: newState.base
});
}
// Update base context if provided
if (contextSwitch.base) {
newState = validateContextTransition(newState, { base: contextSwitch.base });
logger.debug('navigation-state', '✅ Base context updated', {
previous: currentState.base,
new: newState.base
});
}
logger.debug('navigation-state', '✅ Context validation complete', {
validatedState: newState,
originalState: currentState
});
// Determine which context to use for the node
const targetContext = contextSwitch.base ||
contextSwitch.extended ||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) :
newState.base);
// Get database name
const dbName = getContextDatabase(newState, userDbName, workerDbName);
logger.debug('context-switch', '🔍 Fetching default node for context', {
targetContext,
dbName,
currentState: newState
});
// Get default node for the final context
const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName);
if (!defaultNode) {
const errorMsg = `No default node found for context: ${targetContext}`;
logger.error('context-switch', '❌ Default node fetch failed', { targetContext });
set({
error: errorMsg,
isLoading: false
});
return;
}
logger.debug('context-switch', '✨ Default node fetched', {
nodeId: defaultNode.id,
tldraw_snapshot: defaultNode.tldraw_snapshot,
type: defaultNode.type
});
// Update history and state
const newHistory = addToHistory(currentState.history, defaultNode);
logger.debug('history-management', '📚 History updated', {
previousState: currentState.history,
newState: newHistory,
addedNode: defaultNode
});
set({
context: {
...newState,
node: defaultNode,
history: newHistory
},
isLoading: false,
error: null
});
logger.debug('navigation-context', '✅ Context switch completed', {
finalState: {
main: newState.main,
base: newState.base,
nodeId: defaultNode.id
}
});
} catch (error) {
logger.error('navigation-context', '❌ Failed to switch context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to switch context',
isLoading: false
});
}
},
goBack: () => {
const currentState = get().context;
if (currentState.history.currentIndex > 0) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
}
});
}
},
goForward: () => {
const currentState = get().context;
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
}
});
}
},
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ main }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set main context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set main context',
isLoading: false
});
}
},
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ base }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set base context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set base context',
isLoading: false
});
}
},
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ extended }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set extended context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set extended context',
isLoading: false
});
}
},
navigate: async (nodeId: string, dbName: string) => {
try {
set({ isLoading: true, error: null });
// Check if we already have this node in history
const currentState = get().context;
const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
// If node exists in history, just navigate to it
if (existingNodeIndex !== -1) {
logger.debug('navigation', '📍 Navigating to existing node in history', {
nodeId,
historyIndex: existingNodeIndex,
currentIndex: currentState.history.currentIndex
});
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
});
return;
}
// Fetch new node data
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName);
if (!nodeData) {
throw new Error(`Node not found: ${nodeId}`);
}
const node: NavigationNode = {
id: nodeId,
tldraw_snapshot: nodeData.node_data.tldraw_snapshot || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
type: nodeData.node_type
};
logger.debug('navigation', '📍 Adding new node to history', {
nodeId: node.id,
type: node.type,
tldraw_snapshot: node.tldraw_snapshot
});
// Add to history and update state
const newHistory = addToHistory(currentState.history, node);
set({
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
});
} catch (error) {
logger.error('navigation', '❌ Failed to navigate:', error);
set({
error: error instanceof Error ? error.message : 'Failed to navigate',
isLoading: false
});
}
},
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
try {
set({ isLoading: true, error: null });
if (!isValidNodeType(node.type)) {
throw new Error(`Invalid node type: ${node.type}`);
}
const dbName = getContextDatabase(get().context, userDbName, workerDbName);
await get().navigate(node.id, dbName);
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to node:', error);
set({
error: error instanceof Error ? error.message : 'Failed to navigate to node',
isLoading: false
});
}
},
refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => {
try {
set({ isLoading: true, error: null });
const currentState = get().context;
if (currentState.node) {
const dbName = getContextDatabase(currentState, userDbName, workerDbName);
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName);
if (nodeData) {
const node: NavigationNode = {
id: currentState.node.id,
tldraw_snapshot: nodeData.node_data.tldraw_snapshot || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
type: nodeData.node_type
};
set({
context: {
...currentState,
node
}
});
}
}
set({ isLoading: false });
} catch (error) {
logger.error('navigation', '❌ Failed to refresh navigation state:', error);
set({
error: error instanceof Error ? error.message : 'Failed to refresh navigation state',
isLoading: false
});
}
}
}));
+65
View File
@@ -0,0 +1,65 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { logger } from './debugConfig';
const appProtocol = import.meta.env.VITE_APP_PROTOCOL;
const supabaseUrl = `${appProtocol}://${import.meta.env.VITE_SUPABASE_URL}`;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
logger.info('supabase-client', '🔄 Supabase configuration', {
url: supabaseUrl,
key: supabaseAnonKey,
});
if (!supabaseUrl || !supabaseAnonKey) {
logger.error('supabase-client', '❌ Missing Supabase configuration', {
hasUrl: !!supabaseUrl,
hasKey: !!supabaseAnonKey,
});
throw new Error('Missing Supabase configuration');
}
// Lazy-loaded Supabase client instance for non-auth operations
let supabaseInstance: SupabaseClient | null = null;
const getSupabaseClient = () => {
if (!supabaseInstance) {
logger.info('supabase-client', '🔄 Initializing Supabase client');
supabaseInstance = createClient(
supabaseUrl,
supabaseAnonKey,
{
auth: {
flowType: 'pkce',
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
storage: window.localStorage,
storageKey: 'supabase.auth.token',
debug: true
},
global: {
headers: {
'X-Client-Info': 'classroom-copilot',
},
},
}
);
// Log configuration in development
logger.info('supabase-client', '🔄 Supabase client configuration loaded', {
url: supabaseUrl,
hasKey: !!supabaseAnonKey,
storageKey: 'supabase.auth.token'
});
}
return supabaseInstance;
};
// Export a proxy that will lazy load the client when needed
export const supabase = new Proxy({} as SupabaseClient, {
get: (target, prop) => {
const client = getSupabaseClient();
return client[prop as keyof SupabaseClient];
}
});
+286
View File
@@ -0,0 +1,286 @@
/* eslint-env serviceworker */
/// <reference lib="webworker" />
/// <reference types="vite/client" />
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import { ExpirationPlugin } from 'workbox-expiration'
import { BackgroundSyncPlugin } from 'workbox-background-sync'
declare const self: ServiceWorkerGlobalScope
// Only apply service worker logic if on the app subdomain
if (!self.location.hostname.startsWith('app.')) {
console.log('Skipping SW install: not on app domain');
self.skipWaiting();
self.registration?.unregister();
}
// Define cache names with domain awareness
const CACHE_NAMES = {
static: `static-assets-v1-${self.location.hostname}`,
dynamic: `dynamic-content-v1-${self.location.hostname}`,
pages: `pages-v1-${self.location.hostname}`,
api: `api-v1-${self.location.hostname}`,
offline: `offline-v1-${self.location.hostname}`
}
// Clean up old caches
cleanupOutdatedCaches()
// Precache production assets only
// Development resources are excluded by Vite's build configuration
const manifest = self.__WB_MANIFEST;
// Find the index.html entry from the manifest
const manifestIndexEntry = manifest.find(entry => {
const url = typeof entry === 'string' ? entry : entry.url;
return url === 'index.html' || url === '/index.html';
});
// Create index entries using the revision from manifest if available
const indexEntries = [
{
url: '/index.html',
revision: manifestIndexEntry && typeof manifestIndexEntry !== 'string' ? manifestIndexEntry.revision : null
},
{
url: '/',
revision: manifestIndexEntry && typeof manifestIndexEntry !== 'string' ? manifestIndexEntry.revision : null
}
];
// Filter out index.html from manifest and add absolute paths
const manifestWithoutIndex = manifest.filter(entry => {
const url = typeof entry === 'string' ? entry : entry.url;
return url !== 'index.html' && url !== '/index.html';
});
// Combine entries and ensure absolute paths
const manifestWithIndex = [
...indexEntries,
...manifestWithoutIndex.map(entry => {
if (typeof entry === 'string') {
return { url: entry.startsWith('/') ? entry : `/${entry}`, revision: null };
}
return {
...entry,
url: entry.url.startsWith('/') ? entry.url : `/${entry.url}`
};
})
];
// Precache all assets
precacheAndRoute(manifestWithIndex);
// Create a background sync queue for failed requests
const bgSyncPlugin = new BackgroundSyncPlugin('failedRequests', {
maxRetentionTime: 24 * 60 // Retry for up to 24 hours (specified in minutes)
})
// Navigation handler for offline usage
const navigationHandler = async ({ request }: { request: Request }): Promise<Response> => {
try {
// Try network first
const response = await fetch(request);
if (response.ok) {
return response;
}
} catch (error) {
console.log('Navigation fetch failed:', error);
}
// If network fails, try cache
const cache = await caches.open(CACHE_NAMES.pages);
// Try both root and /index.html paths
const cachedResponse = await cache.match(request) ||
await cache.match('/') ||
await cache.match('/index.html');
if (cachedResponse) {
return cachedResponse;
}
// If cache fails, try to fetch index.html directly
try {
const indexResponse = await fetch('/index.html');
if (indexResponse.ok) {
await cache.put('/index.html', indexResponse.clone());
return indexResponse;
}
} catch (error) {
console.error('Failed to fetch index.html:', error);
}
// If all else fails, return a basic offline page
return new Response(
'<html><body><h1>Offline</h1><p>Please check your internet connection.</p></body></html>',
{
headers: { 'Content-Type': 'text/html' }
}
);
};
// Register navigation route
const navigationRoute = new NavigationRoute(navigationHandler, {
denylist: [
/^\/(auth|rest|api|whisperlive|tldraw|searxng-api)/,
/^\/@.*/, // Block all /@vite/, /@react-refresh/, etc.
/^\/src\/.*/ // Block all /src/ paths
]
});
registerRoute(navigationRoute);
// Cache page navigations (html) with a Network First strategy
registerRoute(
// Check to see if the request is a navigation to a new page
({ request }) => request.mode === 'navigate',
new NetworkFirst({
// Put all cached files in a cache named 'pages'
cacheName: CACHE_NAMES.pages,
plugins: [
// Ensure that only requests that result in a 200 status are cached
new CacheableResponsePlugin({
statuses: [200]
})
],
networkTimeoutSeconds: 3
})
)
// Cache manifest and icons with a Stale While Revalidate strategy
registerRoute(
({ request }) =>
request.destination === 'manifest' ||
request.url.includes('/icons/'),
new StaleWhileRevalidate({
cacheName: 'manifest-and-icons',
plugins: [
new CacheableResponsePlugin({
statuses: [200]
})
]
})
)
// Cache other static assets with a Cache First strategy
registerRoute(
({ request }) => {
const destination = request.destination;
return destination === 'style' ||
destination === 'script' ||
destination === 'image' ||
destination === 'font'
},
new CacheFirst({
cacheName: CACHE_NAMES.static,
plugins: [
new CacheableResponsePlugin({
statuses: [200]
}),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
purgeOnQuotaError: true
})
]
})
)
// Cache API responses with a Network First strategy
// TODO: This is a temporary solution to cache API responses. We should move to a more efficient strategy in the future using our new domain names search.classroomcopilot.ai and api.classroomcopilot.ai
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: CACHE_NAMES.api,
plugins: [
new CacheableResponsePlugin({
statuses: [200]
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5 minutes
}),
bgSyncPlugin
]
})
)
// Cache SearXNG API with Network First strategy
registerRoute(
({ url }) => url.pathname.startsWith('/searxng-api'),
new NetworkFirst({
cacheName: CACHE_NAMES.api,
plugins: [
new CacheableResponsePlugin({
statuses: [200]
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 // 1 hour
}),
bgSyncPlugin
]
})
)
// Cache SearXNG static assets
registerRoute(
({ url }) => url.pathname.startsWith('/searxng-api/static'),
new CacheFirst({
cacheName: CACHE_NAMES.static,
plugins: [
new CacheableResponsePlugin({
statuses: [200]
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
})
]
})
)
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
// Handle offline fallback
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
// Enable navigation preload if available
self.registration.navigationPreload?.enable(),
// Delete old caches
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => !Object.values(CACHE_NAMES).includes(cacheName))
.map((cacheName) => caches.delete(cacheName))
)
}),
// Tell the active service worker to take control of the page immediately
self.clients.claim()
])
)
})
// Handle errors
self.addEventListener('error', (event) => {
console.error('[Service Worker] Error:', event.error)
// You could send this to your error tracking service
})
self.addEventListener('unhandledrejection', (event) => {
console.error('[Service Worker] Unhandled rejection:', event.reason)
// You could send this to your error tracking service
})
+38
View File
@@ -0,0 +1,38 @@
import { CCNodeTypes } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
export interface NodeResponse {
node_data: {
__primarylabel__: keyof CCNodeTypes;
unique_id: string;
title?: string;
name?: string;
tldraw_snapshot: string;
created: string;
merged: string;
[key: string]: unknown;
};
}
export interface ConnectedNodeResponse {
id: string;
tldraw_snapshot: string;
label: string;
type: string;
}
export interface RelationshipData {
start_node: NodeResponse['node_data'];
end_node: NodeResponse['node_data'];
relationship_type: string;
relationship_properties: {
[key: string]: unknown;
};
}
export interface ConnectedNodesResponse {
status: string;
main_node: NodeResponse;
connected_nodes: ConnectedNodeResponse[];
relationships: RelationshipData[];
}
+22
View File
@@ -0,0 +1,22 @@
import { TLShapeId } from '@tldraw/tldraw';
export type ShapeState = {
parentId: TLShapeId | null;
isPageChild: boolean;
hasChildren: boolean | null;
bindings: Record<string, unknown> | null;
};
export type NodeData = {
title: string;
w: number;
h: number;
state: ShapeState | null;
headerColor: string;
backgroundColor: string;
isLocked: boolean;
__primarylabel__: string;
unique_id: string;
tldraw_snapshot: string;
[key: string]: string | number | boolean | null | ShapeState | Record<string, unknown> | undefined;
}
+343
View File
@@ -0,0 +1,343 @@
import { CalendarNode, WorkerNode } from '../contexts/NeoUserContext';
import { logger } from '../debugConfig';
// Main Context Types
export type MainContext = 'profile' | 'institute';
// Profile Context Types
export type ProfileContext = 'profile' | 'calendar' | 'teaching';
// Extended Context Types
export type UserExtendedContext = 'overview' | 'settings' | 'history' | 'journal' | 'planner';
export type CalendarExtendedContext = 'overview' | 'day' | 'week' | 'month' | 'year';
export type TeacherExtendedContext =
| 'overview'
| 'timetable'
| 'classes'
| 'lessons'
| 'journal'
| 'planner';
export type SchoolExtendedContext = 'overview' | 'departments' | 'staff';
export type DepartmentExtendedContext = 'overview' | 'teachers' | 'subjects';
export type ClassExtendedContext = 'overview' | 'students' | 'timetable';
// Combined Extended Context Type
export type ExtendedContext =
| UserExtendedContext
| CalendarExtendedContext
| TeacherExtendedContext
| SchoolExtendedContext
| DepartmentExtendedContext
| ClassExtendedContext;
// View Definition
export interface ViewDefinition {
id: ExtendedContext;
icon: string;
label: string;
description: string;
}
// Institute Context Types
export type InstituteContext = 'school' | 'department' | 'class';
// View Context Types
export type ViewContext =
| 'overview'
| 'settings'
| 'history'
| 'journal'
| 'planner'
| 'day'
| 'week'
| 'month'
| 'year'
| 'timetable'
| 'classes'
| 'lessons'
| 'departments'
| 'staff'
| 'teachers'
| 'subjects'
| 'students';
// Combined Context Type
export type BaseContext = ProfileContext | InstituteContext;
// Combined Node Context Type
export type NodeContext = BaseContext | ExtendedContext;
// Navigation History
export interface NavigationHistory {
nodes: NavigationNode[];
currentIndex: number;
}
// Helper function to get current node from history
export const getCurrentHistoryNode = (history: NavigationHistory): NavigationNode | null => {
const node = history.currentIndex === -1 || !history.nodes.length ? null : history.nodes[history.currentIndex];
logger.debug('history-management', '📍 Getting current history node', {
currentIndex: history.currentIndex,
totalNodes: history.nodes.length,
node
});
return node;
};
// Helper function to add node to history
export const addToHistory = (
history: NavigationHistory,
node: NavigationNode
): NavigationHistory => {
logger.debug('history-management', ' Adding node to history', {
currentIndex: history.currentIndex,
newNode: node,
existingNodes: history.nodes.length
});
const newNodes = [...history.nodes.slice(0, history.currentIndex + 1), node];
const newHistory = {
nodes: newNodes,
currentIndex: newNodes.length - 1
};
logger.debug('history-management', '✅ History updated', {
previousState: history,
newState: newHistory
});
return newHistory;
};
// Helper function to navigate history
export const navigateHistory = (
history: NavigationHistory,
index: number
): NavigationHistory => {
logger.debug('history-management', '🔄 Navigating history', {
currentIndex: history.currentIndex,
targetIndex: index,
totalNodes: history.nodes.length
});
if (index < 0 || index >= history.nodes.length) {
logger.warn('history-management', '⚠️ Invalid history navigation index', {
requestedIndex: index,
historyLength: history.nodes.length
});
return history;
}
const newHistory = {
nodes: history.nodes,
currentIndex: index
};
logger.debug('history-management', '✅ History navigation complete', {
from: history.currentIndex,
to: index,
node: history.nodes[index]
});
return newHistory;
};
// Context State Interface
export interface NavigationContextState {
main: MainContext;
base: BaseContext;
node: NavigationNode | null;
history: NavigationHistory;
}
// Type Guards
export const isProfileContext = (context: BaseContext): context is ProfileContext => {
return ['profile', 'calendar', 'teaching'].includes(context);
};
export const isInstituteContext = (context: BaseContext): context is InstituteContext => {
return ['school', 'department', 'class'].includes(context);
};
// Database selector
export const getContextDatabase = (context: NavigationContextState, userDbName: string | null, workerDbName: string | null): string => {
logger.debug('navigation-context', '🔄 Getting context database', {
mainContext: context.main,
baseContext: context.base,
userDbName,
workerDbName
});
if (context.main === 'profile') {
if (!userDbName) {
logger.error('navigation-context', '❌ Missing user database name for profile context');
throw new Error('User database name is required for profile context');
}
logger.debug('navigation-context', '✅ Using user database', { dbName: userDbName });
return userDbName;
} else {
if (!workerDbName) {
logger.error('navigation-context', '❌ Missing worker database name for institute context');
throw new Error('Worker database name is required for institute context');
}
logger.debug('navigation-context', '✅ Using worker database', { dbName: workerDbName });
return workerDbName;
}
};
// Context Definition Types
export interface ContextDefinition {
id: BaseContext;
icon: string;
label: string;
description: string;
defaultNodeId: string;
views: ViewDefinition[];
}
// Navigation Node Types
export interface NavigationNode {
id: string;
tldraw_snapshot: string;
label: string;
type: string;
context?: NavigationContextState;
data?: {
unique_id: string;
tldraw_snapshot: string;
title?: string;
name?: string;
[key: string]: unknown;
};
}
// Navigation State Interface
export interface NavigationState {
context: NavigationContextState;
isLoading: boolean;
error: string | null;
}
// Add UnifiedContextSwitch interface
export interface UnifiedContextSwitch {
main?: MainContext;
base?: BaseContext;
extended?: ExtendedContext;
skipBaseContextLoad?: boolean;
}
// Navigation Actions Interface
export interface NavigationActions {
// Unified Context Switch
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// Context Navigation
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// Node Navigation
navigate: (nodeId: string, dbName: string) => Promise<void>;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// History Navigation
goBack: () => void;
goForward: () => void;
// Utility Methods
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
}
export type NavigationStore = NavigationState & NavigationActions;
// Context Props Interfaces
export interface CalendarContextProps {
navigateToDay: (id: string) => Promise<void>;
navigateToWeek: (id: string) => Promise<void>;
navigateToMonth: (id: string) => Promise<void>;
navigateToYear: (id: string) => Promise<void>;
currentCalendarNode: CalendarNode | null;
}
export interface WorkerContextProps {
navigateToTimetable: (id: string) => Promise<void>;
navigateToClass: (id: string) => Promise<void>;
navigateToLesson: (id: string) => Promise<void>;
navigateToJournal: (id: string) => Promise<void>;
navigateToPlanner: (id: string) => Promise<void>;
currentWorkerNode: WorkerNode | null;
}
export interface StaticNavigationNode extends NavigationNode {
isStatic: true;
order: number;
section: string;
}
// Calendar Structure Types
export interface CalendarDay {
id: string;
date: string;
title: string;
}
export interface CalendarWeek {
id: string;
title: string;
days: { id: string }[];
startDate: string;
endDate: string;
}
export interface CalendarMonth {
id: string;
title: string;
days: { id: string }[];
weeks: { id: string }[];
year: string;
month: string;
}
export interface CalendarYear {
id: string;
title: string;
months: { id: string }[];
year: string;
}
export interface CalendarStructure {
currentDay: string;
days: Record<string, CalendarDay>;
weeks: Record<string, CalendarWeek>;
months: Record<string, CalendarMonth>;
years: CalendarYear[];
}
// Worker Structure Types
export interface TimetableEntry {
id: string;
title: string;
type: string;
startTime: string;
endTime: string;
}
export interface ClassEntry {
id: string;
title: string;
type: string;
}
export interface LessonEntry {
id: string;
title: string;
type: string;
}
export interface WorkerStructure {
timetables: Record<string, TimetableEntry[]>;
classes: Record<string, ClassEntry[]>;
lessons: Record<string, LessonEntry[]>;
journals: Record<string, { id: string; title: string }[]>;
planners: Record<string, { id: string; title: string }[]>;
}
+63
View File
@@ -0,0 +1,63 @@
import { CCNodeTypes } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { NavigationNode } from './navigation';
// Node Type Groups
export type CalendarNodeType =
| 'Calendar'
| 'CalendarYear'
| 'CalendarMonth'
| 'CalendarWeek'
| 'CalendarDay';
export type WorkerNodeType =
| 'Teacher'
| 'UserTeacherTimetable'
| 'UserTimetableLesson';
// Node Types
export type CalendarNode = NavigationNode & {
type: CalendarNodeType;
data: CCNodeTypes[CalendarNodeType]['props'];
};
export type WorkerNode = NavigationNode & {
type: WorkerNodeType;
data: CCNodeTypes[WorkerNodeType]['props'];
};
// Type Guards
export const isCalendarNodeType = (type: string): type is CalendarNodeType => {
return [
'Calendar',
'CalendarYear',
'CalendarMonth',
'CalendarWeek',
'CalendarDay'
].includes(type);
};
export const isWorkerNodeType = (type: string): type is WorkerNodeType => {
return [
'Teacher',
'UserTeacherTimetable',
'UserTimetableLesson'
].includes(type);
};
export const isCalendarNode = (node: NavigationNode): node is CalendarNode => {
return isCalendarNodeType(node.type);
};
export const isWorkerNode = (node: NavigationNode): node is WorkerNode => {
return isWorkerNodeType(node.type);
};
// Backend Response Types
export interface ConnectedNodesResponse {
calendar?: CCNodeTypes['Calendar']['props'];
teacher?: CCNodeTypes['Teacher']['props'];
}
export interface UserNodesResponse {
connectedNodes: ConnectedNodesResponse;
}
BIN
View File
Binary file not shown.
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
interface ExtendedNodeProps extends NodeProps {
layoutDirection: 'TB' | 'LR';
}
const DefaultNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => {
const isHorizontal = layoutDirection === 'LR';
return (
<div style={{ padding: 10, border: '1px solid #ddd', borderRadius: 5, backgroundColor: '#f0f0f0', minWidth: 150 }}>
<div style={{ fontWeight: 'bold', marginBottom: 5 }}>{data.label as string}</div>
{data && Object.entries(data).map(([key, value]) => (
key !== 'label' && <div key={key}><strong>{key}:</strong> {value as string}</div>
))}
<Handle type="source" position={isHorizontal ? Position.Right : Position.Bottom} style={{ background: '#555' }} />
<Handle type="target" position={isHorizontal ? Position.Left : Position.Top} style={{ background: '#555' }} />
</div>
);
};
const UserNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.user_name as string,
'User ID': data.user_id as string,
'User Email': data.user_email as string,
'User Type': data.user_type as string,
'User DB ID': data.worker_db_name as string,
}}
id={data.user_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
const TeacherNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.teacher_name_formal,
'Teacher ID': data.teacher_code,
'Teacher Email': data.teacher_email,
}}
id={data.unique_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
const TeacherTimetableNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.label,
}}
id={data.unique_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
const SubjectClassNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.subject_class_code,
'Year Group': data.year_group,
'Subject': data.subject,
'Subject Code': data.subject_code,
}}
id={data.unique_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
export const nodeTypes = {
default: DefaultNode,
userNode: UserNode,
teacherNode: TeacherNode,
teacherTimetableNode: TeacherTimetableNode,
subjectClassNode: SubjectClassNode,
};
+126
View File
@@ -0,0 +1,126 @@
import { TLUiAssetUrlOverrides } from '@tldraw/tldraw';
// Custom asset URLs
export const customAssets: TLUiAssetUrlOverrides = {
icons: {
'sticker-icon': '/icons/sticker-tool.svg'
}
};
// Blank canvas snapshot template
export const blankCanvasSnapshot = {
store: {
"document:document": {
gridSize: 10,
name: "",
meta: {},
id: "document:document",
typeName: "document"
},
"page:page": {
meta: {},
id: "page:page",
name: "Page 1",
index: "a1",
typeName: "page"
}
},
schema: {
schemaVersion: 2 as const,
storeVersion: 4,
sequences: {
"com.tldraw.store":4,
"com.tldraw.asset":1,
"com.tldraw.camera":1,
"com.tldraw.document":2,
"com.tldraw.instance":25,
"com.tldraw.instance_page_state":5,
"com.tldraw.page":1,
"com.tldraw.instance_presence":5,
"com.tldraw.pointer":1,
"com.tldraw.shape":4,
"com.tldraw.asset.bookmark":2,
"com.tldraw.asset.image":5,
"com.tldraw.asset.video":5,
"com.tldraw.shape.arrow":5,
"com.tldraw.shape.bookmark":2,
"com.tldraw.shape.draw":2,
"com.tldraw.shape.embed":4,
"com.tldraw.shape.frame":0,
"com.tldraw.shape.geo":9,
"com.tldraw.shape.group":0,
"com.tldraw.shape.highlight":1,
"com.tldraw.shape.image":4,
"com.tldraw.shape.line":5,
"com.tldraw.shape.note":8,
"com.tldraw.shape.text":2,
"com.tldraw.shape.video":2,
"com.tldraw.shape.youtube-embed":0,
"com.tldraw.shape.calendar":0,
"com.tldraw.shape.microphone":1,
"com.tldraw.shape.transcriptionText":0,
"com.tldraw.shape.slide":0,"com.tldraw.shape.slideshow":0,
"com.tldraw.shape.user_node":1,
"com.tldraw.shape.developer_node":1,
"com.tldraw.shape.student_node":1,
"com.tldraw.shape.teacher_node":1,
"com.tldraw.shape.calendar_node":1,
"com.tldraw.shape.calendar_year_node":1,
"com.tldraw.shape.calendar_month_node":1,
"com.tldraw.shape.calendar_week_node":1,
"com.tldraw.shape.calendar_day_node":1,
"com.tldraw.shape.calendar_time_chunk_node":1,
"com.tldraw.shape.teacher_timetable_node":1,
"com.tldraw.shape.timetable_lesson_node":1,
"com.tldraw.shape.planned_lesson_node":1,
"com.tldraw.shape.pastoral_structure_node":1,
"com.tldraw.shape.year_group_node":1,
"com.tldraw.shape.curriculum_structure_node":1,
"com.tldraw.shape.key_stage_node":1,
"com.tldraw.shape.key_stage_syllabus_node":1,
"com.tldraw.shape.year_group_syllabus_node":1,
"com.tldraw.shape.subject_node":1,
"com.tldraw.shape.topic_node":1,
"com.tldraw.shape.topic_lesson_node":1,
"com.tldraw.shape.learning_statement_node":1,
"com.tldraw.shape.science_lab_node":1,
"com.tldraw.shape.school_timetable_node":1,
"com.tldraw.shape.academic_year_node":1,
"com.tldraw.shape.academic_term_node":1,
"com.tldraw.shape.academic_week_node":1,
"com.tldraw.shape.academic_day_node":1,
"com.tldraw.shape.academic_period_node":1,
"com.tldraw.shape.registration_period_node":1,
"com.tldraw.shape.school_node":1,
"com.tldraw.shape.department_node":1,
"com.tldraw.shape.room_node":1,
"com.tldraw.shape.subject_class_node":1,
"com.tldraw.shape.general_relationship":1,
"com.tldraw.binding.arrow":0,
"com.tldraw.binding.slide-layout":0
},
recordVersions: {
asset: { version: 1, subTypeKey: "type", subTypeVersions: {} },
camera: { version: 1 },
document: { version: 2 },
instance: { version: 21 },
instance_page_state: { version: 5 },
page: { version: 1 },
shape: { version: 3, subTypeKey: "type", subTypeVersions: {} },
instance_presence: { version: 5 },
pointer: { version: 1 }
}
},
rootShapeIds: [],
bindings: {},
assets: {},
session: {
version: 0,
currentPageId: "page:page",
pageStates: [{
pageId: "page:page",
camera: { x: 0, y: 0, z: 1 },
selectedShapeIds: []
}]
}
};
+14
View File
@@ -0,0 +1,14 @@
// TLDraw bindings
import { TLAnyBindingUtilConstructor } from '@tldraw/tldraw'
import { CCSlideLayoutBindingUtil } from './cc-base/cc-slideshow/CCSlideLayoutBindingUtil'
import { ccBindingProps } from './cc-base/cc-props'
// Export CC bindings
export { ccBindingProps }
// Define all binding utils in a single object for easy maintenance
export const BindingUtils = {
CCSlideLayout: CCSlideLayoutBindingUtil,
}
export const allBindingUtils: TLAnyBindingUtilConstructor[] = Object.values(BindingUtils)
@@ -0,0 +1,138 @@
import React from 'react'
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
import { CCBaseShape } from './cc-types'
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
import { logger } from '../../../debugConfig'
export interface ToolbarItem {
id: string
icon: string | React.ReactNode
label: string
onClick: (e: React.MouseEvent, shape: CCBaseShape) => void
isActive?: boolean
}
export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShapeUtil<T> {
abstract renderContent: (shape: T) => React.ReactElement
indicator(shape: T) {
return (
<rect
width={shape.props.w}
height={shape.props.h}
fill="none"
rx={CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius}
stroke={CC_BASE_STYLE_CONSTANTS.COLORS.border}
strokeWidth={CC_BASE_STYLE_CONSTANTS.CONTAINER.borderWidth}
/>
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getToolbarItems(shape: T): ToolbarItem[] {
return []
}
onAfterCreate(shape: T) {
logger.info('cc-base-shape-util', 'onAfterCreate', shape)
return shape
}
component(shape: T) {
const {
props: { w, h, isLocked },
} = shape
const toolbarItems = this.getToolbarItems(shape)
return (
<HTMLContainer
id={shape.id}
style={{
width: toDomPrecision(w),
height: toDomPrecision(h),
backgroundColor: shape.props.headerColor,
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
overflow: 'hidden',
position: 'relative',
}}
>
{/* Header */}
<div
style={{
backgroundColor: shape.props.headerColor,
padding: CC_BASE_STYLE_CONSTANTS.HEADER.padding,
height: CC_BASE_STYLE_CONSTANTS.HEADER.height,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: isLocked ? 'not-allowed' : 'move',
pointerEvents: 'all',
position: 'relative',
zIndex: 1,
}}
>
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
{toolbarItems.map((item) => (
<button
key={item.id}
title={item.label}
onClick={(e) => {
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
e.preventDefault()
e.stopPropagation()
item.onClick(e, shape)
}}
onPointerDown={(e) => {
logger.info('cc-base-shape-util', 'toolbar item pointer down', item.id)
e.preventDefault()
e.stopPropagation()
}}
style={{
background: 'transparent',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: 'white',
opacity: item.isActive ? 1 : 0.7,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
fontSize: '16px',
width: '24px',
height: '24px',
zIndex: 100,
userSelect: 'none',
position: 'relative',
touchAction: 'none',
}}
>
<div style={{ pointerEvents: 'none' }}>
{item.icon}
</div>
</button>
))}
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
</div>
</div>
{/* Content */}
<div
style={{
position: 'absolute',
top: CC_BASE_STYLE_CONSTANTS.HEADER.height,
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
backgroundColor: shape.props.backgroundColor,
}}
>
{this.renderContent(shape)}
</div>
</HTMLContainer>
)
}
}
@@ -0,0 +1,78 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { ccShapeProps, getDefaultCCCalendarProps } from '../cc-props'
import { ccShapeMigrations } from '../cc-migrations'
import { Rectangle2d } from 'tldraw'
import { CalendarComponent, CalendarViewType } from './CalendarComponent'
import { TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
export interface CCCalendarShape extends CCBaseShape {
type: 'cc-calendar'
props: CCBaseShape['props'] & {
date: string
selectedDate: string
view: CalendarViewType
events: TeacherTimetableEvent[]
}
}
export class CCCalendarShapeUtil extends CCBaseShapeUtil<CCCalendarShape> {
static override type = 'cc-calendar' as const;
static override props = ccShapeProps.calendar;
static override migrations = ccShapeMigrations.calendar;
override getDefaultProps(): CCCalendarShape['props'] {
return getDefaultCCCalendarProps() as CCCalendarShape['props'];
}
override isAspectRatioLocked = () => true
override canResize = () => true
override canBind = () => false
onCreate = (shape: CCCalendarShape) => {
// Force a resize after creation to ensure calendar renders correctly
setTimeout(() => {
const element = document.getElementById(shape.id)
if (element) {
const calendar = element.querySelector('.fc') as HTMLElement
if (calendar) {
calendar.style.height = `${shape.props.h}px`
}
}
}, 0)
return {
...shape,
props: this.getDefaultProps(),
}
}
override renderContent = (shape: CCCalendarShape) => {
return <CalendarComponent shape={shape} />
}
override getGeometry(shape: CCCalendarShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
override onResize = (
shape: CCCalendarShape,
info: { initialShape: CCCalendarShape; scaleX: number; scaleY: number }
) => {
const { initialShape, scaleX, scaleY } = info
const newW = Math.max(300, Math.round(initialShape.props.w * scaleX))
const newH = Math.max(200, Math.round(initialShape.props.h * scaleY))
return {
props: {
...shape.props,
w: newW,
h: newH,
},
}
}
}
@@ -0,0 +1,274 @@
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { HTMLContainer, useEditor, useDialogs } from '@tldraw/tldraw'
import FullCalendar from '@fullcalendar/react'
import { DateSelectArg, ViewMountArg } from '@fullcalendar/core'
import { useNeoUser } from '../../../../contexts/NeoUserContext'
import { LoadingState } from '../../../../services/tldraw/snapshotService'
import { TimetableNeoDBService, TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
import { useCalendarOptions } from './useCalendarOptions'
import { openTldrawFile } from './utils'
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles'
import { CCCalendarShape } from './CCCalendarShapeUtil'
import { ClassFilterDialog, ViewMenuDialog, EventDetailsDialog } from './CalendarDialogs'
export type CalendarViewType = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listYear' | 'listMonth' | 'listWeek' | 'listDay' | 'timeGridYear' | 'timeGridMonth'
interface CalendarComponentProps {
shape: CCCalendarShape
}
export const CalendarComponent: React.FC<CalendarComponentProps> = ({ shape }) => {
const editor = useEditor()
const { addDialog } = useDialogs()
const { workerNode, isLoading, error, workerDbName } = useNeoUser()
const [events, setEvents] = useState<TeacherTimetableEvent[]>(shape.props.events)
const calendarRef = useRef<FullCalendar>(null)
const lastFetchRef = useRef<number>(0)
const timeoutIdRef = useRef<ReturnType<typeof setTimeout>>()
const [selectedClasses, setSelectedClasses] = useState<string[]>([])
const [subjectClasses, setSubjectClasses] = useState<string[]>([])
const [fileLoadingState, setFileLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
})
const debouncedUpdateSize = useCallback(() => {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(() => {
if (calendarRef.current) {
calendarRef.current.getApi().updateSize();
}
}, 200);
}, [calendarRef]);
const loadEvents = useCallback(async () => {
const now = Date.now()
if (now - lastFetchRef.current < 60000) {
return;
}
lastFetchRef.current = now
if ((events && events.length > 0) || isLoading || error || !workerNode?.nodeData) {
if (isLoading || error || !workerNode?.nodeData) {
console.error('Unable to fetch events: NeoUser context not ready');
}
return;
}
try {
const fetchedEvents = await TimetableNeoDBService.fetchTeacherTimetableEvents(
workerNode.nodeData.unique_id,
workerDbName || ''
);
setEvents(fetchedEvents);
const range = TimetableNeoDBService.getEventRange(fetchedEvents);
if (calendarRef.current) {
const api = calendarRef.current.getApi();
if (range.start && range.end) {
api.setOption('validRange', {
start: range.start,
end: range.end
});
} else {
api.setOption('validRange', undefined);
}
}
editor.updateShape({
id: shape.id,
type: 'cc-calendar',
props: { ...shape.props, events: fetchedEvents }
})
} catch (error) {
console.error('Error fetching calendar events:', error)
}
}, [editor, workerNode, workerDbName, isLoading, error, events, shape.id, shape.props])
useEffect(() => {
debouncedUpdateSize();
}, [debouncedUpdateSize]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
const handleDateSelect = useCallback((arg: DateSelectArg) => {
editor.updateShape<CCCalendarShape>({
id: shape.id,
type: 'cc-calendar',
props: { ...shape.props, selectedDate: arg.start.toISOString() }
})
}, [editor, shape.id, shape.props])
const handleViewChange = useCallback((mountArg: ViewMountArg) => {
const newView = mountArg.view.type as CCCalendarShape['props']['view']
editor.updateShape<CCCalendarShape>({
id: shape.id,
type: 'cc-calendar',
props: { ...shape.props, view: newView }
})
}, [editor, shape.id, shape.props])
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
}, [])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
}, [])
const handleWheel = useCallback((e: React.WheelEvent) => {
e.stopPropagation()
}, [])
const handleClassToggle = (subjectClass: string) => {
setSelectedClasses(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
};
useEffect(() => {
const classes = Array.from(new Set(events.map(event => event.extendedProps?.subjectClass || '')));
setSubjectClasses(classes);
setSelectedClasses(classes);
}, [events]);
const filteredEvents = events.filter(event =>
selectedClasses.includes(event.extendedProps?.subjectClass || '')
);
// Calculate available height for calendar
const getAvailableHeight = useCallback(() => {
const totalPadding = CC_BASE_STYLE_CONSTANTS.CONTENT.padding * 2
const availableHeight = shape.props.h - CC_BASE_STYLE_CONSTANTS.HEADER.height - totalPadding
return Math.max(availableHeight, CC_BASE_STYLE_CONSTANTS.MIN_DIMENSIONS.height)
}, [shape.props.h])
// Calculate available width for calendar
const getAvailableWidth = useCallback(() => {
const totalPadding = CC_BASE_STYLE_CONSTANTS.CONTENT.padding * 2
const availableWidth = shape.props.w - totalPadding
return Math.max(availableWidth, CC_BASE_STYLE_CONSTANTS.MIN_DIMENSIONS.width)
}, [shape.props.w])
const showClassFilterDialog = useCallback(() => {
addDialog({
component: ({ onClose }) => (
<ClassFilterDialog
onClose={onClose}
subjectClasses={subjectClasses}
selectedClasses={selectedClasses}
onClassToggle={handleClassToggle}
events={events}
/>
),
onClose: () => {
// Optional cleanup
},
})
}, [addDialog, subjectClasses, selectedClasses, events, handleClassToggle])
const showViewMenuDialog = useCallback(() => {
addDialog({
component: ({ onClose }) => (
<ViewMenuDialog
onSelect={(view) => {
calendarRef.current?.getApi().changeView(view)
onClose()
}}
onClose={onClose}
/>
),
onClose: () => {
// Optional cleanup
},
})
}, [addDialog, calendarRef])
const showEventDetailsDialog = useCallback((event: TeacherTimetableEvent) => {
addDialog({
component: ({ onClose }) => (
<EventDetailsDialog
onClose={onClose}
selectedEvent={event}
fileLoadingState={fileLoadingState}
editor={editor}
workerDbName={workerDbName || ''}
onOpenFile={openTldrawFile}
setFileLoadingState={setFileLoadingState}
/>
),
onClose: () => {
// Optional cleanup
},
})
}, [addDialog, fileLoadingState, editor, workerDbName])
const calendarOptions = useCalendarOptions({
shape: {
...shape,
props: {
...shape.props,
h: getAvailableHeight(),
}
},
filteredEvents,
handleDateSelect,
handleViewChange,
toggleClassFilterModal: showClassFilterDialog,
setIsViewMenuOpen: () => showViewMenuDialog(),
setSelectedEvent: (event: TeacherTimetableEvent | null) => {
if (event) showEventDetailsDialog(event)
},
setIsEventModalOpen: () => {}, // No longer needed
});
// Update calendar size when shape dimensions change
useEffect(() => {
if (calendarRef.current) {
const calendar = calendarRef.current.getApi();
calendar.updateSize();
}
}, [shape.props.w, shape.props.h]);
return (
<HTMLContainer
id={shape.id}
style={{
width: getAvailableWidth(),
height: getAvailableHeight(),
position: 'relative',
overflow: 'hidden',
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
color: 'black',
pointerEvents: 'all',
display: 'flex',
flexDirection: 'column',
}}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
onWheel={handleWheel}
>
<div style={{
width: '100%',
height: '100%',
overflow: 'hidden'
}}>
<FullCalendar
ref={calendarRef}
{...calendarOptions}
/>
</div>
</HTMLContainer>
)
}
@@ -0,0 +1,184 @@
import {
TldrawUiButton,
TldrawUiButtonLabel,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogFooter,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
Editor
} from '@tldraw/tldraw'
import { FaCheck, FaExternalLinkAlt } from 'react-icons/fa'
import { TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
import { LoadingState } from '../../../../services/tldraw/snapshotService'
// View Menu Dialog Component
interface ViewMenuDialogProps {
onSelect: (view: string) => void
onClose: () => void
}
export const ViewMenuDialog = ({ onSelect, onClose }: ViewMenuDialogProps) => {
const views = [
{ name: 'Day', view: 'timeGridDay' },
{ name: 'Week', view: 'timeGridWeek' },
{ name: 'Month', view: 'dayGridMonth' },
{ name: 'Year', view: 'dayGridYear' },
{ name: 'List Day', view: 'listDay' },
{ name: 'List Week', view: 'listWeek' },
{ name: 'List Month', view: 'listMonth' },
{ name: 'List Year', view: 'listYear' },
]
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Select Calendar View</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{views.map(({name, view}) => (
<TldrawUiButton key={view} type="normal" onClick={() => onSelect(view)}>
<TldrawUiButtonLabel>{name}</TldrawUiButtonLabel>
</TldrawUiButton>
))}
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="normal" onClick={onClose}>
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
// Class Filter Dialog
interface ClassFilterDialogProps {
onClose: () => void
subjectClasses: string[]
selectedClasses: string[]
onClassToggle: (subjectClass: string) => void
events: TeacherTimetableEvent[]
}
export const ClassFilterDialog = ({
onClose,
subjectClasses,
selectedClasses,
onClassToggle,
events
}: ClassFilterDialogProps) => {
const renderClassFilterButton = (subjectClass: string) => {
const isSelected = selectedClasses.includes(subjectClass)
const color = events.find(e => e.extendedProps?.subjectClass === subjectClass)?.extendedProps?.color || '#000000'
return (
<TldrawUiButton
key={subjectClass}
type="normal"
onClick={() => onClassToggle(subjectClass)}
style={{
backgroundColor: isSelected ? color : '#ffffff',
color: isSelected ? '#ffffff' : '#000000',
border: `2px solid ${color}`,
}}
>
<TldrawUiButtonLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ marginRight: '8px' }}>
{isSelected && <FaCheck />}
</div>
<span>{subjectClass}</span>
</div>
</TldrawUiButtonLabel>
</TldrawUiButton>
)
}
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Filter Classes</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{subjectClasses.map(renderClassFilterButton)}
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="normal" onClick={onClose}>
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
// Event Details Dialog
interface EventDetailsDialogProps {
onClose: () => void
selectedEvent: TeacherTimetableEvent | null
fileLoadingState: LoadingState
editor: Editor
workerDbName: string | undefined
onOpenFile: (path: string, dbName: string, editor: Editor, setFileLoadingState: (state: LoadingState) => void) => void
setFileLoadingState: (state: LoadingState) => void
}
export const EventDetailsDialog = ({
onClose,
selectedEvent,
fileLoadingState,
editor,
workerDbName,
onOpenFile,
setFileLoadingState
}: EventDetailsDialogProps) => {
const handleOpenFile = () => {
if (!selectedEvent?.extendedProps?.tldraw_snapshot || !workerDbName) {
console.error('❌ Failed to open tldraw file - missing snapshot or db name')
return
}
onOpenFile(
selectedEvent.extendedProps.tldraw_snapshot,
workerDbName,
editor,
setFileLoadingState
)
}
if (!selectedEvent) return null
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>{selectedEvent.title}</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody>
<p><strong>Start Time:</strong> {new Date(selectedEvent.start).toLocaleString()}</p>
<p><strong>End Time:</strong> {new Date(selectedEvent.end).toLocaleString()}</p>
<p><strong>Class:</strong> {selectedEvent.extendedProps?.subjectClass}</p>
<p><strong>Period:</strong> {selectedEvent.extendedProps?.periodCode}</p>
{fileLoadingState.status === 'loading' && <p>Loading file...</p>}
{fileLoadingState.status === 'error' && (
<p style={{color: 'red'}}>Error: {fileLoadingState.error}</p>
)}
{selectedEvent.extendedProps?.tldraw_snapshot && fileLoadingState.status !== 'loading' && (
<TldrawUiButton type="normal" onClick={handleOpenFile}>
<TldrawUiButtonLabel>
Open Tldraw File <FaExternalLinkAlt style={{ marginLeft: '8px' }} />
</TldrawUiButtonLabel>
</TldrawUiButton>
)}
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="normal" onClick={onClose}>
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
@@ -0,0 +1,131 @@
import { CalendarOptions, EventContentArg, formatDate, DateSelectArg, ViewMountArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import { TimetableNeoDBService } from '../../../../services/graph/timetableNeoDBService'
import { CC_CALENDAR_STYLE_CONSTANTS } from '../cc-styles'
// Calendar layout constants
export const CALENDAR_LAYOUT = {
TOOLBAR_HEIGHT: 5,
HEADER_PADDING: 5,
CONTENT_PADDING: 5,
FOOTER_HEIGHT: 0,
// Function to calculate available content height
getAvailableHeight: (totalHeight: number) => {
return totalHeight - (
CALENDAR_LAYOUT.TOOLBAR_HEIGHT +
CALENDAR_LAYOUT.HEADER_PADDING * 2 +
CALENDAR_LAYOUT.CONTENT_PADDING * 2 +
CALENDAR_LAYOUT.FOOTER_HEIGHT
);
}
} as const;
// Re-export types that other files might need
export type { CalendarOptions, EventContentArg, DateSelectArg, ViewMountArg }
// Export plugins
export const getCalendarPlugins = () => [
dayGridPlugin,
timeGridPlugin,
listPlugin,
interactionPlugin,
]
// Export event content renderer
export const renderEventContent = (arg: EventContentArg) => {
const backgroundColor = TimetableNeoDBService.lightenColor(arg.event.extendedProps?.color || '#000000', 100);
const textColor = TimetableNeoDBService.getContrastColor(backgroundColor);
const formatEventTime = (date: Date | null) => {
if (!date) {
return '';
}
return formatDate(date, {
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZone: 'local'
});
};
const formattedStartTime = formatEventTime(arg.event.start); //DO NOT REMOVE THIS COMMENT
const formattedEndTime = formatEventTime(arg.event.end); //DO NOT REMOVE THIS COMMENT
const timeAndPeriodCodeString = `${arg.event.extendedProps?.periodCode || ''}`; //DO NOT REMOVE THIS COMMENT
return {
html: `
<div class="fc-event-main-frame" style="background-color: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.backgroundColor}; color: ${textColor}; display: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.display}; align-items: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.alignItems}; justify-content: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.justifyContent}; min-height: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.minHeight}; padding: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.padding}; border-radius: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.borderRadius};">
<div class="fc-event-title fc-sticky" style="color: ${textColor}; font-size: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.fontSize}; font-weight: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.fontWeight}; text-align: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.textAlign}; overflow: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.overflow}; text-overflow: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.textOverflow}; white-space: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.whiteSpace}; opacity: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.opacity}; padding: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.padding}; width: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.width}; letter-spacing: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.letterSpacing}; margin: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.margin};">${arg.event.title}</div>
</div>
`
};
}
// Export base calendar options
export const getBaseCalendarOptions = (): Partial<CalendarOptions> => ({
aspectRatio: undefined,
expandRows: true,
stickyHeaderDates: true,
stickyFooterScrollbar: false,
dayHeaders: true,
dayHeaderFormat(arg) {
return formatDate(arg.date.marker, { weekday: 'long' });
},
titleFormat: (arg) => {
return formatDate(arg.date.marker, { weekday: 'long' });
},
dayMaxEvents: 10,
selectable: true,
eventMinHeight: 10,
navLinks: true,
hiddenDays: [1],
weekends: false,
slotMinTime: '08:00:00',
slotMaxTime: '16:00:00',
slotDuration: '00:30:00',
slotLabelInterval: '01:00',
allDaySlot: false,
slotEventOverlap: true,
dayMaxEventRows: 1,
eventMaxStack: 1,
slotLabelFormat: [
{
formatMatcher: 'best fit',
hour: 'numeric',
minute: '2-digit',
omitZeroMinute: true,
meridiem: 'narrow',
hour12: false,
}
],
buttonText: {
today: 'Today',
month: 'Month',
week: 'Week',
day: 'Day',
list: 'List',
},
buttonIcons: {
prev: 'chevron-left',
next: 'chevron-right',
prevYear: 'chevrons-left',
nextYear: 'chevrons-right',
},
themeSystem: 'standard',
eventContent: renderEventContent,
eventClassNames: (arg: { event: { extendedProps?: { subjectClass?: string; color?: string } } }) => {
return [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 = TimetableNeoDBService.lightenColor(originalColor, 100);
arg.el.style.backgroundColor = lightenedColor;
arg.el.style.borderColor = originalColor;
arg.el.style.color = TimetableNeoDBService.getContrastColor(lightenedColor);
}
},
})
@@ -0,0 +1,62 @@
import { TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
import { getBaseCalendarOptions, getCalendarPlugins, CalendarOptions, DateSelectArg, ViewMountArg, CALENDAR_LAYOUT } from './calendarOptions'
interface UseCalendarOptionsProps {
shape: {
props: {
view: string
h: number
}
}
filteredEvents: TeacherTimetableEvent[]
handleDateSelect: (arg: DateSelectArg) => void
handleViewChange: (arg: ViewMountArg) => void
toggleClassFilterModal: () => void
setIsViewMenuOpen: (isOpen: boolean) => void
setSelectedEvent: (event: TeacherTimetableEvent | null) => void
setIsEventModalOpen: (isOpen: boolean) => void
}
export const useCalendarOptions = ({
shape,
filteredEvents,
handleDateSelect,
handleViewChange,
toggleClassFilterModal,
setIsViewMenuOpen,
setSelectedEvent,
setIsEventModalOpen,
}: UseCalendarOptionsProps): CalendarOptions => {
const availableHeight = CALENDAR_LAYOUT.getAvailableHeight(shape.props.h);
return {
...getBaseCalendarOptions(),
plugins: getCalendarPlugins(),
initialView: shape.props.view,
events: filteredEvents,
select: handleDateSelect,
viewDidMount: handleViewChange,
height: availableHeight,
contentHeight: availableHeight,
headerToolbar: {
left: 'prev,next today viewToggle',
center: 'title',
right: 'filterClasses'
},
customButtons: {
filterClasses: {
text: 'Classes',
click: toggleClassFilterModal,
},
viewToggle: {
text: 'View',
click: () => setIsViewMenuOpen(true),
},
},
eventClick: (arg) => {
const eventData = arg.event.toPlainObject();
setSelectedEvent(eventData as unknown as TeacherTimetableEvent);
setIsEventModalOpen(true);
},
};
};

Some files were not shown because too many files have changed in this diff Show More