Initial commit
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user