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
@@ -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';