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