Initial commit
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Box, Typography, Button, Container, useTheme } from "@mui/material";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
function NotFound() {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug('not-found', '🔄 Not Found page rendered', {
|
||||
hasUser: !!user,
|
||||
userId: user?.id
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const handleReturn = () => {
|
||||
const returnPath = user ? '/single-player' : '/';
|
||||
logger.debug('not-found', '🔄 Navigating to return path', { returnPath });
|
||||
navigate(returnPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
textAlign: 'center',
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
404
|
||||
</Typography>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Page Not Found
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
Return to {user ? 'Canvas' : 'Home'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { EventContentArg, EventClickArg, CalendarOptions } from '@fullcalendar/core';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import multiMonthPlugin from '@fullcalendar/multimonth'; // Import the multiMonth plugin for year view
|
||||
import listPlugin from '@fullcalendar/list';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
||||
import { FaEllipsisV } from 'react-icons/fa';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
groupId?: string;
|
||||
extendedProps?: {
|
||||
subjectClass: string;
|
||||
color: string;
|
||||
periodCode: string;
|
||||
tldraw_snapshot?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function lightenColor(color: string, amount: number): string {
|
||||
// Remove the '#' if it exists
|
||||
color = color.replace(/^#/, '');
|
||||
|
||||
// Parse the color
|
||||
let r = parseInt(color.slice(0, 2), 16);
|
||||
let g = parseInt(color.slice(2, 4), 16);
|
||||
let b = parseInt(color.slice(4, 6), 16);
|
||||
|
||||
// Convert to HSL
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
|
||||
// Adjust the lightness based on the current lightness
|
||||
const newL = l < 0.5 ? l + (1 - l) * amount : l + (1 - l) * amount * 0.5;
|
||||
|
||||
// Convert back to RGB
|
||||
[r, g, b] = hslToRgb(h, s, newL);
|
||||
|
||||
// Convert to hex and return
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0; // achromatic
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
let r, g, b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) {
|
||||
t += 1;
|
||||
}
|
||||
if (t > 1) {
|
||||
t -= 1;
|
||||
}
|
||||
if (t < 1/6) {
|
||||
return p + (q - p) * 6 * t;
|
||||
}
|
||||
if (t < 1/2) {
|
||||
return q;
|
||||
}
|
||||
if (t < 2/3) {
|
||||
return p + (q - p) * (2/3 - t) * 6;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1/3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||
}
|
||||
|
||||
const CalendarPage: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||
const { user } = useAuth();
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||
const [hiddenSubjectClassDivs, setHiddenSubjectClassDivs] = useState<string[]>([]);
|
||||
const [hiddenPeriodCodeDivs, setHiddenPeriodCodeDivs] = useState<string[]>([]);
|
||||
const [hiddenTimeDivs, setHiddenTimeDivs] = useState<string[]>([]);
|
||||
const [eventRange, setEventRange] = useState<{ start: Date | null; end: Date | null }>({ start: null, end: null });
|
||||
const { workerNode, isLoading, error, workerDbName } = useNeoUser();
|
||||
|
||||
const getEventRange = useCallback((events: Event[]) => {
|
||||
if (events.length === 0) {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
let start = new Date(events[0].start);
|
||||
let end = new Date(events[0].end);
|
||||
|
||||
events.forEach(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
const eventEnd = new Date(event.end);
|
||||
if (eventStart < start) {
|
||||
start = eventStart;
|
||||
}
|
||||
if (eventEnd > end) {
|
||||
end = eventEnd;
|
||||
}
|
||||
});
|
||||
|
||||
// Adjust start to the beginning of its month and end to the end of its month
|
||||
start.setDate(1);
|
||||
end.setMonth(end.getMonth() + 1, 0);
|
||||
|
||||
return { start, end };
|
||||
}, []);
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
if (!user || isLoading || error || !workerNode?.nodeData) {
|
||||
if (error) {
|
||||
logger.error('calendar', 'NeoUser context error', { error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('calendar', 'Fetching events', {
|
||||
unique_id: workerNode.nodeData.unique_id,
|
||||
school_db_name: workerDbName
|
||||
});
|
||||
|
||||
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
|
||||
workerNode.nodeData.unique_id,
|
||||
workerDbName || ''
|
||||
);
|
||||
|
||||
const transformedEvents = events.map(event => ({
|
||||
...event,
|
||||
extendedProps: {
|
||||
...event.extendedProps,
|
||||
tldraw_snapshot: workerNode?.nodeData?.tldraw_snapshot
|
||||
}
|
||||
}));
|
||||
|
||||
setEvents(transformedEvents);
|
||||
|
||||
const classes: string[] = [];
|
||||
transformedEvents.forEach((event: Event) => {
|
||||
if (event.extendedProps?.subjectClass && !classes.includes(event.extendedProps.subjectClass)) {
|
||||
classes.push(event.extendedProps.subjectClass);
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedClasses(classes);
|
||||
|
||||
const range = getEventRange(transformedEvents);
|
||||
setEventRange(range);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('calendar', 'Error fetching events', { error });
|
||||
}
|
||||
}, [user, workerNode, workerDbName, isLoading, error, getEventRange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, [fetchEvents]);
|
||||
|
||||
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
|
||||
const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot;
|
||||
if (tldraw_snapshot) {
|
||||
// TODO: Implement tldraw_snapshot retrieval from storage API
|
||||
// For now, we'll just log it
|
||||
console.log('TLDraw snapshot:', tldraw_snapshot);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredEvents = useMemo(() =>
|
||||
events.filter(event =>
|
||||
selectedClasses.includes(event.extendedProps?.subjectClass || '')
|
||||
), [events, selectedClasses]
|
||||
);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (calendarRef.current) {
|
||||
calendarRef.current.getApi().updateSize();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
const toggleDropdown = useCallback((eventId: string) => {
|
||||
setOpenDropdownId(openDropdownId === eventId ? null : eventId);
|
||||
}, [openDropdownId]);
|
||||
|
||||
const toggleSubjectClassDivVisibility = useCallback((subjectClass: string) => {
|
||||
setHiddenSubjectClassDivs(prev =>
|
||||
prev.includes(subjectClass)
|
||||
? prev.filter(c => c !== subjectClass)
|
||||
: [...prev, subjectClass]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const togglePeriodCodeDivVisibility = useCallback((subjectClass: string) => {
|
||||
setHiddenPeriodCodeDivs(prev =>
|
||||
prev.includes(subjectClass)
|
||||
? prev.filter(c => c !== subjectClass)
|
||||
: [...prev, subjectClass]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleTimeDivVisibility = useCallback((subjectClass: string) => {
|
||||
setHiddenTimeDivs(prev =>
|
||||
prev.includes(subjectClass)
|
||||
? prev.filter(c => c !== subjectClass)
|
||||
: [...prev, subjectClass]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const hideSubjectClassFromView = useCallback((subjectClass: string) => {
|
||||
setSelectedClasses(prev => prev.filter(c => c !== subjectClass));
|
||||
}, []);
|
||||
|
||||
const toggleAllDivs = useCallback((subjectClass: string, hide: boolean) => {
|
||||
const updateHiddenDivs = (prev: string[]) =>
|
||||
hide ? [...prev, subjectClass] : prev.filter(c => c !== subjectClass);
|
||||
|
||||
setHiddenSubjectClassDivs(updateHiddenDivs);
|
||||
setHiddenPeriodCodeDivs(updateHiddenDivs);
|
||||
setHiddenTimeDivs(updateHiddenDivs);
|
||||
}, []);
|
||||
|
||||
const areAllDivsHidden = useCallback((subjectClass: string) => {
|
||||
return hiddenSubjectClassDivs.includes(subjectClass) &&
|
||||
hiddenPeriodCodeDivs.includes(subjectClass) &&
|
||||
hiddenTimeDivs.includes(subjectClass);
|
||||
}, [hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs]);
|
||||
|
||||
const renderEventContent = useCallback((eventInfo: EventContentArg) => {
|
||||
const { event } = eventInfo;
|
||||
const subjectClass = event.extendedProps?.subjectClass || 'Subject Class';
|
||||
const originalColor = event.extendedProps?.color || '#ffffff';
|
||||
const lightenedColor = lightenColor(originalColor, 0.9);
|
||||
|
||||
const eventStyle = {
|
||||
backgroundColor: lightenedColor,
|
||||
color: '#000',
|
||||
padding: '4px 6px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '1.0em',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
height: '100%',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
border: `2px solid ${originalColor}`,
|
||||
position: 'relative' as const,
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontWeight: 'bold' as const,
|
||||
whiteSpace: 'nowrap' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
paddingRight: '20px',
|
||||
};
|
||||
|
||||
const contentStyle = {
|
||||
fontSize: '0.8em',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
};
|
||||
|
||||
const ellipsisStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`custom-event-content ${openDropdownId === event.id ? 'event-with-dropdown' : ''}`} style={eventStyle}>
|
||||
<div style={titleStyle}>{event.title}</div>
|
||||
<div style={ellipsisStyle}>
|
||||
<FaEllipsisV onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown(event.id);
|
||||
}} />
|
||||
</div>
|
||||
{openDropdownId === event.id && (
|
||||
<div className="event-dropdown" style={{ position: 'absolute'}}>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
hideSubjectClassFromView(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
Hide this class from view
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleAllDivs(subjectClass, !areAllDivsHidden(subjectClass));
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{areAllDivsHidden(subjectClass) ? 'Show' : 'Hide'} all divs
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSubjectClassDivVisibility(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{hiddenSubjectClassDivs.includes(subjectClass) ? 'Show' : 'Hide'} subject class
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePeriodCodeDivVisibility(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{hiddenPeriodCodeDivs.includes(subjectClass) ? 'Show' : 'Hide'} period code
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTimeDivVisibility(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{hiddenTimeDivs.includes(subjectClass) ? 'Show' : 'Hide'} time
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hiddenSubjectClassDivs.includes(subjectClass) && (
|
||||
<div style={contentStyle} className="event-subject-class">{subjectClass}</div>
|
||||
)}
|
||||
{!hiddenPeriodCodeDivs.includes(subjectClass) && (
|
||||
<div style={contentStyle} className="event-period">{event.extendedProps?.periodCode || 'Period Code'}</div>
|
||||
)}
|
||||
{!hiddenTimeDivs.includes(subjectClass) && (
|
||||
<div style={contentStyle} className="event-time">
|
||||
{event.start?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
|
||||
{event.end?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [openDropdownId, hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs, toggleDropdown, hideSubjectClassFromView, toggleAllDivs, areAllDivsHidden, toggleSubjectClassDivVisibility, togglePeriodCodeDivVisibility, toggleTimeDivVisibility]);
|
||||
|
||||
const calendarOptions: CalendarOptions = useMemo(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, multiMonthPlugin, listPlugin],
|
||||
initialView: "timeGridWeek",
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'viewToggle filterClassesButton'
|
||||
},
|
||||
customButtons: {
|
||||
filterClassesButton: {
|
||||
text: 'Filter Classes',
|
||||
click: () => {} // We'll implement this differently later
|
||||
},
|
||||
viewToggle: {
|
||||
text: 'Change View',
|
||||
click: () => {} // We'll implement this differently later
|
||||
}
|
||||
},
|
||||
views: {
|
||||
dayGridYear: {
|
||||
type: 'dayGrid',
|
||||
duration: { years: 1 },
|
||||
buttonText: 'Year Grid',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
dayGridMonth: {
|
||||
buttonText: 'Month',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
timeGridWeek: {
|
||||
buttonText: 'Week',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
timeGridDay: {
|
||||
buttonText: 'Day',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listYear: {
|
||||
buttonText: 'List Year',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listMonth: {
|
||||
buttonText: 'List Month',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listWeek: {
|
||||
buttonText: 'List Week',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listDay: {
|
||||
buttonText: 'List Day',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
},
|
||||
validRange: eventRange.start && eventRange.end ? {
|
||||
start: eventRange.start,
|
||||
end: eventRange.end
|
||||
} : undefined,
|
||||
events: filteredEvents,
|
||||
height: "100%",
|
||||
slotMinTime: "08:00:00",
|
||||
slotMaxTime: "17:00:00",
|
||||
allDaySlot: false,
|
||||
expandRows: true,
|
||||
slotEventOverlap: false,
|
||||
slotDuration: "00:30:00",
|
||||
slotLabelInterval: "01:00",
|
||||
eventContent: renderEventContent,
|
||||
eventClassNames: (arg: { event: { extendedProps?: { subjectClass?: string } } }) =>
|
||||
[arg.event.extendedProps?.subjectClass || ''],
|
||||
eventDidMount: (arg: { event: { extendedProps?: { color?: string }; id: string }; el: HTMLElement }) => {
|
||||
if (arg.event.extendedProps?.color) {
|
||||
const originalColor = arg.event.extendedProps.color;
|
||||
const lightenedColor = lightenColor(originalColor, 0.4);
|
||||
arg.el.style.backgroundColor = lightenedColor;
|
||||
arg.el.style.borderColor = originalColor;
|
||||
}
|
||||
|
||||
const updateEventContent = () => {
|
||||
const height = arg.el.offsetHeight;
|
||||
const contentElements = arg.el.querySelectorAll('.custom-event-content > div:not(.event-dropdown)');
|
||||
|
||||
contentElements.forEach((el, index) => {
|
||||
const element = el as HTMLElement;
|
||||
if (index === 0 || index === 1) {
|
||||
element.style.display = 'block';
|
||||
} else if (height >= 40 && index === 2) {
|
||||
element.style.display = 'block';
|
||||
} else if (height >= 60 && index === 3) {
|
||||
element.style.display = 'block';
|
||||
} else if (height >= 80 && index === 4) {
|
||||
element.style.display = 'block';
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updateEventContent();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateEventContent);
|
||||
resizeObserver.observe(arg.el);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
},
|
||||
eventClick: handleEventClick,
|
||||
}), [eventRange.start, eventRange.end, filteredEvents, renderEventContent, handleEventClick]);
|
||||
|
||||
if (!user) {
|
||||
console.log('User not logged in');
|
||||
return <div>Please log in to view your calendar.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calendar-page">
|
||||
<div className="calendar-container" style={{ height: '100vh', position: 'relative' }}>
|
||||
<FullCalendar
|
||||
{...calendarOptions}
|
||||
ref={calendarRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Box,
|
||||
Button,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
||||
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
|
||||
import { CCTeacherNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { user, user_role } = useAuth();
|
||||
const { userNode, workerNode } = useNeoUser();
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Check if user is a teacher (includes both email and MS teachers)
|
||||
const isTeacher = user_role?.includes('teacher');
|
||||
|
||||
const handleTimetableUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
setUploadSuccess(null);
|
||||
const result = await TimetableNeoDBService.handleTimetableUpload(
|
||||
event.target.files?.[0],
|
||||
userNode || undefined,
|
||||
workerNode?.nodeData as CCTeacherNodeProps | undefined
|
||||
);
|
||||
if (result.success) {
|
||||
setUploadSuccess(result.message);
|
||||
} else {
|
||||
setUploadError(result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
{/* User Info Section */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
User Information
|
||||
</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body1">
|
||||
Email: {user?.email}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Role: {user_role}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Timetable Upload Section - Only visible for teachers */}
|
||||
{isTeacher && (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Timetable Management
|
||||
</Typography>
|
||||
|
||||
{!userNode && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Your workspace is being set up. Some features may be limited until setup is complete.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadSuccess && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{uploadSuccess}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
disabled={isUploading || !workerNode}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Upload Timetable'}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".xlsx"
|
||||
onChange={handleTimetableUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Upload your timetable in Excel (.xlsx) format
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Additional settings sections can be added here */}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
Reference in New Issue
Block a user