feat(phase-a+b): merge clean auth + Supabase navigation to master
Merges Phase A (Neo4j removed from startup) and Phase B (Supabase navigation, school/timetable setup, graph nav panel) into master. Phase A: NeoUserProvider removed, CCUser simplified, snapshotService null-safe Phase B: navigationStore → Supabase whiteboard_rooms, snapshotService → Supabase Storage, CCGraphNavPanel, SchoolCalendarWizard, TeacherTimetableWizard, CIS auth fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
b0b2a7f2c3
16
src/App.tsx
16
src/App.tsx
@ -4,31 +4,23 @@ import { theme } from './services/themeService';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { TLDrawProvider } from './contexts/TLDrawContext';
|
import { TLDrawProvider } from './contexts/TLDrawContext';
|
||||||
import { UserProvider } from './contexts/UserContext';
|
import { UserProvider } from './contexts/UserContext';
|
||||||
import { NeoUserProvider } from './contexts/NeoUserContext';
|
|
||||||
import { NeoInstituteProvider } from './contexts/NeoInstituteContext';
|
|
||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// Wrap the entire app in a memo to prevent unnecessary re-renders
|
|
||||||
const App = React.memo(() => (
|
const App = React.memo(() => (
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<NeoUserProvider>
|
<TLDrawProvider>
|
||||||
<NeoInstituteProvider>
|
<AppRoutes />
|
||||||
<TLDrawProvider>
|
</TLDrawProvider>
|
||||||
<AppRoutes />
|
|
||||||
</TLDrawProvider>
|
|
||||||
</NeoInstituteProvider>
|
|
||||||
</NeoUserProvider>
|
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
));
|
));
|
||||||
|
|
||||||
// Add display name for better debugging
|
|
||||||
App.displayName = import.meta.env.VITE_APP_NAME;
|
App.displayName = import.meta.env.VITE_APP_NAME;
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -1,458 +1,121 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IconButton,
|
IconButton, Tooltip, Box, Menu, MenuItem,
|
||||||
Tooltip,
|
ListItemIcon, ListItemText, Chip, styled,
|
||||||
Box,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Button,
|
|
||||||
styled
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack as ArrowBackIcon,
|
ArrowBack as ArrowBackIcon,
|
||||||
ArrowForward as ArrowForwardIcon,
|
ArrowForward as ArrowForwardIcon,
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
School as SchoolIcon,
|
Home as HomeIcon,
|
||||||
Person as PersonIcon,
|
CalendarToday,
|
||||||
AccountCircle as AccountCircleIcon,
|
DateRange,
|
||||||
CalendarToday as CalendarIcon,
|
Event,
|
||||||
School as TeachingIcon,
|
WorkspacesOutlined,
|
||||||
Business as BusinessIcon,
|
|
||||||
AccountTree as DepartmentIcon,
|
|
||||||
Class as ClassIcon,
|
|
||||||
ExpandMore as ExpandMoreIcon
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigationStore } from '../../stores/navigationStore';
|
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)`
|
const NavigationRoot = styled(Box)`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NavigationControls = styled(Box)`
|
function getNodeIcon(nodeType: string) {
|
||||||
display: flex;
|
switch (nodeType) {
|
||||||
align-items: center;
|
case 'User': return <HomeIcon fontSize="small" />;
|
||||||
gap: 4px;
|
case 'CalendarYear': return <CalendarToday fontSize="small" />;
|
||||||
`;
|
case 'CalendarMonth': return <DateRange fontSize="small" />;
|
||||||
|
case 'CalendarDay': return <Event fontSize="small" />;
|
||||||
const ContextToggleContainer = styled(Box)(({ theme }) => ({
|
default: return <WorkspacesOutlined fontSize="small" />;
|
||||||
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 = () => {
|
export const GraphNavigator: React.FC = () => {
|
||||||
const {
|
const { context, goBack, goForward, isLoading } = useNavigationStore();
|
||||||
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 [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 { history } = context;
|
||||||
const canGoBack = history.currentIndex > 0;
|
const canGoBack = history.currentIndex > 0;
|
||||||
const canGoForward = history.currentIndex < history.nodes.length - 1;
|
const canGoForward = history.currentIndex < history.nodes.length - 1;
|
||||||
|
const currentNode = context.node;
|
||||||
|
|
||||||
|
const handleHistoryClick = (e: React.MouseEvent<HTMLElement>) => setHistoryMenuAnchor(e.currentTarget);
|
||||||
|
const handleHistoryClose = () => setHistoryMenuAnchor(null);
|
||||||
|
const handleHistoryItemClick = (index: number) => {
|
||||||
|
const delta = index - history.currentIndex;
|
||||||
|
if (delta < 0) for (let i = 0; i < -delta; i++) goBack();
|
||||||
|
else if (delta > 0) for (let i = 0; i < delta; i++) goForward();
|
||||||
|
handleHistoryClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationRoot ref={rootRef}>
|
<NavigationRoot>
|
||||||
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
|
<Tooltip title="Back">
|
||||||
<Tooltip title="Back">
|
<span>
|
||||||
<span>
|
<IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
|
||||||
<IconButton
|
<ArrowBackIcon fontSize="small" />
|
||||||
onClick={goBack}
|
</IconButton>
|
||||||
disabled={!canGoBack || isDisabled}
|
</span>
|
||||||
size="small"
|
</Tooltip>
|
||||||
>
|
|
||||||
<ArrowBackIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="History">
|
<Tooltip title="History">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleHistoryClick}
|
onClick={handleHistoryClick}
|
||||||
disabled={!history.nodes.length || isDisabled}
|
disabled={!history.nodes.length}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
<HistoryIcon fontSize="small" />
|
<HistoryIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip title="Forward">
|
<Tooltip title="Forward">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
|
||||||
onClick={goForward}
|
<ArrowForwardIcon fontSize="small" />
|
||||||
disabled={!canGoForward || isDisabled}
|
</IconButton>
|
||||||
size="small"
|
</span>
|
||||||
>
|
</Tooltip>
|
||||||
<ArrowForwardIcon fontSize="small" />
|
|
||||||
</IconButton>
|
{currentNode && (
|
||||||
</span>
|
<Chip
|
||||||
</Tooltip>
|
size="small"
|
||||||
</NavigationControls>
|
icon={getNodeIcon(currentNode.type)}
|
||||||
|
label={currentNode.label || currentNode.type}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ maxWidth: 200, fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* History Menu */}
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={historyMenuAnchor}
|
anchorEl={historyMenuAnchor}
|
||||||
open={Boolean(historyMenuAnchor)}
|
open={Boolean(historyMenuAnchor)}
|
||||||
onClose={handleHistoryClose}
|
onClose={handleHistoryClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
vertical: 'bottom',
|
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
horizontal: 'center',
|
|
||||||
}}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: 'top',
|
|
||||||
horizontal: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{history.nodes.map((node, index) => (
|
{history.nodes.map((node, index) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={`${node.id}-${index}`}
|
key={`${node.id}-${index}`}
|
||||||
onClick={() => handleHistoryItemClick(index)}
|
onClick={() => handleHistoryItemClick(index)}
|
||||||
selected={index === history.currentIndex}
|
selected={index === history.currentIndex}
|
||||||
|
dense
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||||
{getContextIcon(node.type)}
|
{getNodeIcon(node.type)}
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={node.label || node.id}
|
primary={node.label || node.id}
|
||||||
secondary={node.type}
|
secondary={node.type}
|
||||||
|
primaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.7rem' }}
|
||||||
/>
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</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>
|
</NavigationRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
225
src/components/navigation/GraphSidebar.tsx
Normal file
225
src/components/navigation/GraphSidebar.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box, IconButton, CircularProgress, Tooltip, Collapse,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ChevronLeft, ChevronRight,
|
||||||
|
ExpandMore, ChevronRight as ChevronRightIcon,
|
||||||
|
Home as HomeIcon, CalendarToday, DateRange, Event,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigationStore } from '../../stores/navigationStore';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { NeoGraphNode } from '../../types/navigation';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
interface TreeNode extends NeoGraphNode {
|
||||||
|
has_children?: boolean;
|
||||||
|
children?: TreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_ICONS: Record<string, React.ElementType> = {
|
||||||
|
User: HomeIcon,
|
||||||
|
CalendarYear: CalendarToday,
|
||||||
|
CalendarMonth: DateRange,
|
||||||
|
CalendarWeek: DateRange,
|
||||||
|
CalendarDay: Event,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH = 220;
|
||||||
|
|
||||||
|
interface TreeItemProps {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
onSelect: (node: TreeNode) => void;
|
||||||
|
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
|
||||||
|
activeRoomId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const Icon = NODE_ICONS[node.node_type] || HomeIcon;
|
||||||
|
const canExpand = node.has_children !== false && node.node_type !== 'CalendarDay';
|
||||||
|
|
||||||
|
const handleToggle = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!expanded && children.length === 0 && canExpand) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const loaded = await onExpand(node);
|
||||||
|
setChildren(loaded);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpanded(v => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
onClick={() => onSelect(node)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.4,
|
||||||
|
cursor: 'pointer', borderRadius: 1, mx: 0.5,
|
||||||
|
fontSize: '0.78rem', minHeight: 28,
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||||
|
{canExpand && (
|
||||||
|
loading
|
||||||
|
? <CircularProgress size={10} />
|
||||||
|
: (
|
||||||
|
<IconButton
|
||||||
|
size="small" sx={{ p: 0, color: 'text.secondary' }}
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? <ExpandMore sx={{ fontSize: 14 }} />
|
||||||
|
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Icon sx={{ fontSize: 14, mr: 0.75, flexShrink: 0, color: 'text.secondary' }} />
|
||||||
|
<Box sx={{
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
flexGrow: 1, color: 'text.primary',
|
||||||
|
}}>
|
||||||
|
{node.label}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{canExpand && (
|
||||||
|
<Collapse in={expanded} timeout="auto">
|
||||||
|
{children.map(child => (
|
||||||
|
<TreeItem
|
||||||
|
key={child.neo4j_node_id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onExpand={onExpand}
|
||||||
|
activeRoomId={activeRoomId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphSidebar({ open, onToggle }: GraphSidebarProps) {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const { navigateToNeoNode, context } = useNavigationStore();
|
||||||
|
const [tree, setTree] = useState<TreeNode | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||||
|
|
||||||
|
const fetchTree = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/graph/tree`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Graph tree fetch failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setTree(data.tree);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('graph-sidebar', 'Failed to load graph tree', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !tree && accessToken) fetchTree();
|
||||||
|
}, [open, tree, accessToken, fetchTree]);
|
||||||
|
|
||||||
|
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
|
||||||
|
if (!accessToken) return [];
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
neo4j_node_id: node.neo4j_node_id,
|
||||||
|
neo4j_db_name: node.neo4j_db_name,
|
||||||
|
node_type: node.node_type,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.children || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'relative', height: '100%', display: 'flex', flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: open ? SIDEBAR_WIDTH : 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRight: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ overflowY: 'auto', flexGrow: 1, pt: 1 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
) : tree ? (
|
||||||
|
<TreeItem
|
||||||
|
node={tree}
|
||||||
|
depth={0}
|
||||||
|
onSelect={n => navigateToNeoNode(n)}
|
||||||
|
onExpand={handleExpand}
|
||||||
|
activeRoomId={context.node?.id}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title={open ? 'Collapse sidebar' : 'Expand sidebar'} placement="right">
|
||||||
|
<IconButton
|
||||||
|
onClick={onToggle}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -14,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
width: 22,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open
|
||||||
|
? <ChevronLeft sx={{ fontSize: 14 }} />
|
||||||
|
: <ChevronRight sx={{ fontSize: 14 }} />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,12 +4,12 @@ import { Session, User } from '@supabase/supabase-js';
|
|||||||
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { supabase } from '../supabaseClient';
|
import { supabase } from '../supabaseClient';
|
||||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
|
||||||
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
user: CCUser | null;
|
user: CCUser | null;
|
||||||
user_role: string | null;
|
user_role: string | null;
|
||||||
|
accessToken: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
signIn: (email: string, password: string) => Promise<void>;
|
signIn: (email: string, password: string) => Promise<void>;
|
||||||
@ -20,6 +20,7 @@ export interface AuthContextType {
|
|||||||
export const AuthContext = createContext<AuthContextType>({
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
user: null,
|
user: null,
|
||||||
user_role: null,
|
user_role: null,
|
||||||
|
accessToken: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
signIn: async () => {},
|
signIn: async () => {},
|
||||||
@ -31,9 +32,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [user, setUser] = useState<CCUser | null>(null);
|
const [user, setUser] = useState<CCUser | null>(null);
|
||||||
const [user_role, setUserRole] = useState<string | null>(null);
|
const [user_role, setUserRole] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||||
|
|
||||||
const persistSession = useCallback((session: Session | null) => {
|
const persistSession = useCallback((session: Session | null) => {
|
||||||
if (session) {
|
if (session) {
|
||||||
storageService.set(StorageKeys.SUPABASE_SESSION, session);
|
storageService.set(StorageKeys.SUPABASE_SESSION, session);
|
||||||
@ -52,20 +56,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
|
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
|
||||||
const userType = (metadata.user_type || 'email_teacher').trim();
|
const userType = (metadata.user_type || 'email_teacher').trim();
|
||||||
|
|
||||||
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
|
|
||||||
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
|
||||||
|
|
||||||
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(userType || 'standard', supabaseUser.id);
|
|
||||||
const schoolDbName = storedSchoolDb || '';
|
|
||||||
|
|
||||||
const resolvedUser: CCUser = {
|
const resolvedUser: CCUser = {
|
||||||
id: supabaseUser.id,
|
id: supabaseUser.id,
|
||||||
email: supabaseUser.email,
|
email: supabaseUser.email,
|
||||||
user_type: userType,
|
user_type: userType,
|
||||||
username: baseUsername,
|
username: baseUsername,
|
||||||
display_name: baseDisplayName,
|
display_name: baseDisplayName,
|
||||||
user_db_name: userDbName,
|
school_id: null,
|
||||||
school_db_name: schoolDbName,
|
|
||||||
created_at: supabaseUser.created_at,
|
created_at: supabaseUser.created_at,
|
||||||
updated_at: supabaseUser.updated_at
|
updated_at: supabaseUser.updated_at
|
||||||
};
|
};
|
||||||
@ -74,53 +71,82 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return { user: resolvedUser, role: resolvedRole };
|
return { user: resolvedUser, role: resolvedRole };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const triggerUserInit = useCallback((token: string) => {
|
||||||
|
fetch(`${apiBase}/user/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => logger.debug('auth-context', '✅ User init', data))
|
||||||
|
.catch(err => logger.warn('auth-context', '⚠️ User init failed', { err }));
|
||||||
|
}, [apiBase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Canonical Supabase auth pattern: rely solely on onAuthStateChange.
|
|
||||||
// INITIAL_SESSION fires immediately with the current session state,
|
|
||||||
// eliminating the race condition between loadInitialSession + onAuthStateChange.
|
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
|
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
|
||||||
|
|
||||||
switch (event) {
|
if (event === 'SIGNED_IN') {
|
||||||
case 'INITIAL_SESSION':
|
persistSession(session ?? null);
|
||||||
case 'SIGNED_IN':
|
if (session?.user) {
|
||||||
case 'TOKEN_REFRESHED': {
|
try {
|
||||||
persistSession(session ?? null);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
if (session?.user) {
|
setUser(resolvedUser);
|
||||||
try {
|
setUserRole(role);
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
setAccessToken(session.access_token ?? null);
|
||||||
setUser(resolvedUser);
|
triggerUserInit(session.access_token);
|
||||||
setUserRole(role);
|
} catch (buildError) {
|
||||||
} catch (buildError) {
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||||
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
|
||||||
setUser(null);
|
|
||||||
setUserRole(null);
|
|
||||||
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
||||||
}
|
}
|
||||||
// Always clear loading after the first auth event resolves
|
} else {
|
||||||
setLoading(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'SIGNED_OUT': {
|
|
||||||
persistSession(null);
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
setLoading(false);
|
setAccessToken(null);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
default:
|
setLoading(false);
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||||
|
persistSession(session ?? null);
|
||||||
|
if (session?.user) {
|
||||||
|
try {
|
||||||
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
|
setUser(resolvedUser);
|
||||||
|
setUserRole(role);
|
||||||
|
setAccessToken(session.access_token ?? null);
|
||||||
|
} catch (buildError) {
|
||||||
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'SIGNED_OUT') {
|
||||||
|
persistSession(null);
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [buildUserFromSupabase, persistSession]);
|
}, [buildUserFromSupabase, persistSession, triggerUserInit]);
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
@ -140,6 +166,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
|
||||||
setUser(resolvedUser);
|
setUser(resolvedUser);
|
||||||
setUserRole(role);
|
setUserRole(role);
|
||||||
|
setAccessToken(data.session?.access_token ?? null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('auth-context', '❌ Sign in failed', { error });
|
logger.error('auth-context', '❌ Sign in failed', { error });
|
||||||
@ -173,6 +200,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
user_role,
|
user_role,
|
||||||
|
accessToken,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
signIn,
|
signIn,
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useAuth } from './AuthContext';
|
|||||||
import { useUser } from './UserContext';
|
import { useUser } from './UserContext';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
|
||||||
import { CalendarStructure, WorkerStructure } from '../types/navigation';
|
import { CalendarStructure, WorkerStructure } from '../types/navigation';
|
||||||
import { useNavigationStore } from '../stores/navigationStore';
|
import { useNavigationStore } from '../stores/navigationStore';
|
||||||
|
|
||||||
@ -131,7 +130,7 @@ const NeoUserContext = createContext<NeoUserContextType>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const { user } = useAuth();
|
const { user, accessToken } = useAuth();
|
||||||
const { profile, isInitialized: isUserInitialized } = useUser();
|
const { profile, isInitialized: isUserInitialized } = useUser();
|
||||||
const navigationStore = useNavigationStore();
|
const navigationStore = useNavigationStore();
|
||||||
|
|
||||||
@ -215,12 +214,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Set database names
|
// Inject auth into navigation store so Supabase queries work
|
||||||
const userDb = profile.user_db_name || (user?.email ?
|
if (user?.id && accessToken) {
|
||||||
DatabaseNameService.getStoredUserDatabase() || null : null);
|
navigationStore.setAuthInfo(accessToken, user.id);
|
||||||
|
|
||||||
if (!userDb) {
|
|
||||||
throw new Error('No user database name available');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize user node in profile context
|
// Initialize user node in profile context
|
||||||
@ -236,7 +232,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
main: 'profile',
|
main: 'profile',
|
||||||
base: 'profile',
|
base: 'profile',
|
||||||
extended: 'overview'
|
extended: 'overview'
|
||||||
}, userDb, profile.school_db_name),
|
}, null, null),
|
||||||
switchTimeout
|
switchTimeout
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -271,9 +267,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
// Continue without user node - this is not critical for basic functionality
|
// Continue without user node - this is not critical for basic functionality
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set final state
|
// Set final state — userDbName signals auth availability for UI guards
|
||||||
setUserDbName(userDb);
|
setUserDbName(user?.id || null);
|
||||||
setWorkerDbName(profile.school_db_name);
|
setWorkerDbName(null);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
initializationRef.current.isComplete = true;
|
initializationRef.current.isComplete = true;
|
||||||
@ -294,13 +290,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
// Calendar Navigation Functions
|
// Calendar Navigation Functions
|
||||||
const navigateToDay = async (id: string) => {
|
const navigateToDay = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'day'
|
extended: 'day'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -334,13 +330,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToWeek = async (id: string) => {
|
const navigateToWeek = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'week'
|
extended: 'week'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -374,13 +370,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToMonth = async (id: string) => {
|
const navigateToMonth = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'month'
|
extended: 'month'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -414,13 +410,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToYear = async (id: string) => {
|
const navigateToYear = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'year'
|
extended: 'year'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -455,13 +451,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
// Worker Navigation Functions
|
// Worker Navigation Functions
|
||||||
const navigateToTimetable = async (id: string) => {
|
const navigateToTimetable = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'timetable'
|
extended: 'timetable'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -492,13 +488,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToJournal = async (id: string) => {
|
const navigateToJournal = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'journal'
|
extended: 'journal'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -529,13 +525,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToPlanner = async (id: string) => {
|
const navigateToPlanner = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'planner'
|
extended: 'planner'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -566,14 +562,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToClass = async (id: string) => {
|
const navigateToClass = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'classes'
|
extended: 'classes'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
await navigationStore.navigate(id, userDbName);
|
await navigationStore.navigate(id, '');
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@ -604,14 +600,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToLesson = async (id: string) => {
|
const navigateToLesson = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'lessons'
|
extended: 'lessons'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
await navigationStore.navigate(id, userDbName);
|
await navigationStore.navigate(id, '');
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import { supabase } from '../supabaseClient';
|
|||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
||||||
import { UserPreferences } from '../services/auth/profileService';
|
import { UserPreferences } from '../services/auth/profileService';
|
||||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
|
||||||
import { provisionUser } from '../services/provisioningService';
|
|
||||||
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||||
|
|
||||||
export interface UserContextType {
|
export interface UserContextType {
|
||||||
@ -112,33 +110,23 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
let profileRow: Record<string, unknown> | null = null;
|
let profileRow: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
// Fast-path: build profile from auth metadata + localStorage immediately.
|
// Fast-path: build profile from auth metadata immediately (no spinner on refresh).
|
||||||
// This clears the spinner before any network call so the page renders on refresh
|
|
||||||
// without waiting for the Supabase profiles query (~200ms).
|
|
||||||
const fastMetadata = userInfo.user_metadata as CCUserMetadata;
|
const fastMetadata = userInfo.user_metadata as CCUserMetadata;
|
||||||
const fastStoredUserDb = DatabaseNameService.getStoredUserDatabase();
|
|
||||||
const fastStoredSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
|
||||||
const fastUserDb = fastStoredUserDb || DatabaseNameService.getUserPrivateDB(fastMetadata?.user_type || '', userInfo.id);
|
|
||||||
const fastProfile: CCUser = {
|
const fastProfile: CCUser = {
|
||||||
id: userInfo.id,
|
id: userInfo.id,
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
user_type: fastMetadata?.user_type || '',
|
user_type: fastMetadata?.user_type || '',
|
||||||
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
|
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
|
||||||
display_name: String(fastMetadata?.display_name || ''),
|
display_name: String(fastMetadata?.display_name || ''),
|
||||||
user_db_name: String(fastUserDb || ''),
|
school_id: null,
|
||||||
school_db_name: String(fastStoredSchoolDb || ''),
|
|
||||||
created_at: userInfo.created_at,
|
created_at: userInfo.created_at,
|
||||||
updated_at: userInfo.updated_at
|
updated_at: userInfo.updated_at
|
||||||
};
|
};
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: fastProfile.user_db_name,
|
|
||||||
schoolDbName: fastProfile.school_db_name
|
|
||||||
});
|
|
||||||
if (mountedRef.current && !isInitialized) {
|
if (mountedRef.current && !isInitialized) {
|
||||||
setProfile(fastProfile);
|
setProfile(fastProfile);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata, no spinner');
|
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name).
|
// Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name).
|
||||||
@ -172,80 +160,15 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
hasSchoolDb: !!profileRow?.school_db_name
|
hasSchoolDb: !!profileRow?.school_db_name
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const metadata = userInfo.user_metadata as CCUserMetadata;
|
const metadata = userInfo.user_metadata as CCUserMetadata;
|
||||||
logger.debug('user-context', '🔧 Step 7: Processing user metadata...', {
|
|
||||||
hasMetadata: !!metadata,
|
|
||||||
userType: metadata?.user_type
|
|
||||||
});
|
|
||||||
let userDbName = profileRow?.user_db_name ?? null;
|
|
||||||
let schoolDbName = profileRow?.school_db_name ?? null;
|
|
||||||
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
|
|
||||||
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
|
||||||
|
|
||||||
// Start provisioning in background (non-blocking)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const provisioningPromise = provisionUser(userInfo.id, authSession?.access_token ?? null)
|
|
||||||
.then(provisioned => {
|
|
||||||
if (provisioned) {
|
|
||||||
logger.debug('user-context', '✅ Provisioning completed in background', {
|
|
||||||
userDbName: provisioned.user_db_name,
|
|
||||||
workerDbName: provisioned.worker_db_name
|
|
||||||
});
|
|
||||||
// Update localStorage with provisioned values
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: provisioned.user_db_name,
|
|
||||||
schoolDbName: provisioned.worker_db_name || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(provisionError => {
|
|
||||||
logger.warn('user-context', '⚠️ Background provisioning failed', {
|
|
||||||
userId: userInfo?.id,
|
|
||||||
provisionError: provisionError instanceof Error ? provisionError.message : String(provisionError)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userDbName && storedUserDb) {
|
|
||||||
userDbName = storedUserDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!schoolDbName && storedSchoolDb) {
|
|
||||||
schoolDbName = storedSchoolDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('user-context', 'ℹ️ Database name resolution', {
|
|
||||||
userDbName,
|
|
||||||
schoolDbName
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userDbName) {
|
|
||||||
userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', userInfo.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!schoolDbName) {
|
|
||||||
schoolDbName = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: String(userDbName || ''),
|
|
||||||
schoolDbName: String(schoolDbName || '')
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('user-context', '🔧 Creating user profile object...', {
|
|
||||||
userId: userInfo.id,
|
|
||||||
userDbName,
|
|
||||||
schoolDbName,
|
|
||||||
userType: metadata.user_type
|
|
||||||
});
|
|
||||||
|
|
||||||
const userProfile: CCUser = {
|
const userProfile: CCUser = {
|
||||||
id: userInfo.id,
|
id: userInfo.id,
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
user_type: metadata.user_type || '',
|
user_type: metadata.user_type || '',
|
||||||
username: metadata.username || '',
|
username: metadata.username || '',
|
||||||
display_name: String(metadata.display_name || ''),
|
display_name: String(metadata.display_name || ''),
|
||||||
user_db_name: String(userDbName || ''),
|
school_id: (profileRow?.school_id as string) ?? null,
|
||||||
school_db_name: String(schoolDbName || ''),
|
|
||||||
created_at: userInfo.created_at,
|
created_at: userInfo.created_at,
|
||||||
updated_at: userInfo.updated_at
|
updated_at: userInfo.updated_at
|
||||||
};
|
};
|
||||||
@ -268,9 +191,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
logger.debug('user-context', '✅ User profile loaded', {
|
logger.debug('user-context', '✅ User profile loaded', {
|
||||||
userId: userProfile.id,
|
userId: userProfile.id,
|
||||||
userType: userProfile.user_type,
|
userType: userProfile.user_type,
|
||||||
username: userProfile.username,
|
username: userProfile.username
|
||||||
userDbName: userProfile.user_db_name,
|
|
||||||
schoolDbName: userProfile.school_db_name
|
|
||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -295,22 +216,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
user_type: metadata?.user_type || 'email_teacher',
|
user_type: metadata?.user_type || 'email_teacher',
|
||||||
username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
|
username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
|
||||||
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
|
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
|
||||||
user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id),
|
school_id: null,
|
||||||
school_db_name: '',
|
|
||||||
created_at: userInfo.created_at,
|
created_at: userInfo.created_at,
|
||||||
updated_at: userInfo.updated_at
|
updated_at: userInfo.updated_at
|
||||||
};
|
};
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: fallbackProfile.user_db_name,
|
|
||||||
schoolDbName: fallbackProfile.school_db_name
|
|
||||||
});
|
|
||||||
|
|
||||||
setProfile(fallbackProfile);
|
setProfile(fallbackProfile);
|
||||||
logger.debug('user-context', '✅ Fallback profile created', {
|
logger.debug('user-context', '✅ Fallback profile created', {
|
||||||
userId: fallbackProfile.id,
|
userId: fallbackProfile.id,
|
||||||
userType: fallbackProfile.user_type,
|
userType: fallbackProfile.user_type
|
||||||
userDbName: fallbackProfile.user_db_name
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
|
|||||||
37
src/hooks/useDeviceContext.ts
Normal file
37
src/hooks/useDeviceContext.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type DeviceType = 'desktop' | 'tablet' | 'phone' | 'iwb';
|
||||||
|
|
||||||
|
function detectDeviceType(): DeviceType {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const touchPoints = navigator.maxTouchPoints ?? 0;
|
||||||
|
const hasTouch = touchPoints > 0 || 'ontouchstart' in window;
|
||||||
|
|
||||||
|
if (width >= 1280 && !hasTouch) return 'desktop';
|
||||||
|
if (width >= 768 && hasTouch) return 'tablet';
|
||||||
|
if (width < 768) return 'phone';
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cc_device_type';
|
||||||
|
|
||||||
|
export function useDeviceContext() {
|
||||||
|
const [deviceType, setDeviceTypeState] = useState<DeviceType>(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY) as DeviceType | null;
|
||||||
|
if (stored && ['desktop', 'tablet', 'phone', 'iwb'].includes(stored)) return stored;
|
||||||
|
return detectDeviceType();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, deviceType);
|
||||||
|
}, [deviceType]);
|
||||||
|
|
||||||
|
const setDeviceType = (type: DeviceType) => {
|
||||||
|
setDeviceTypeState(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTouch = deviceType === 'tablet' || deviceType === 'phone';
|
||||||
|
const isMobileLayout = deviceType === 'phone';
|
||||||
|
|
||||||
|
return { deviceType, setDeviceType, isTouch, isMobileLayout };
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||||
import { SelectChangeEvent } from '@mui/material/Select';
|
import { SelectChangeEvent } from '@mui/material/Select';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type Manifest = {
|
type Manifest = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
@ -41,6 +41,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
combinedBundles?: Array<{ id: string }>;
|
combinedBundles?: Array<{ id: string }>;
|
||||||
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
|
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [manifest, setManifest] = useState<Manifest | null>(null);
|
const [manifest, setManifest] = useState<Manifest | null>(null);
|
||||||
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
|
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
|
||||||
const [mode, setMode] = useState<Mode>('markdown_full');
|
const [mode, setMode] = useState<Mode>('markdown_full');
|
||||||
@ -53,9 +54,9 @@ export const CCBundleViewer: React.FC<{
|
|||||||
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
|
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
|
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`;
|
return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`;
|
||||||
}, [API_BASE]);
|
}, [API_BASE, accessToken]);
|
||||||
|
|
||||||
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
|
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
|
||||||
if (!s || typeof s !== 'string') return s || '';
|
if (!s || typeof s !== 'string') return s || '';
|
||||||
@ -222,7 +223,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
setManifest(null);
|
setManifest(null);
|
||||||
if (combinedBundles && combinedBundles.length > 0) {
|
if (combinedBundles && combinedBundles.length > 0) {
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const ms: Manifest[] = [];
|
const ms: Manifest[] = [];
|
||||||
for (const b of combinedBundles) {
|
for (const b of combinedBundles) {
|
||||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
@ -239,7 +240,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
}
|
}
|
||||||
if (!bundleId) return;
|
if (!bundleId) return;
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const rawManifest: Manifest = await res.json();
|
const rawManifest: Manifest = await res.json();
|
||||||
@ -266,7 +267,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
let textParts: string[] = [];
|
let textParts: string[] = [];
|
||||||
let jsonParts: string[] = [];
|
let jsonParts: string[] = [];
|
||||||
for (const m of combinedManifests) {
|
for (const m of combinedManifests) {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
let rel: string | undefined;
|
let rel: string | undefined;
|
||||||
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
|
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
|
||||||
else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full;
|
else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full;
|
||||||
@ -348,7 +349,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
relPath = rec?.path;
|
relPath = rec?.path;
|
||||||
}
|
}
|
||||||
if (!relPath) { setContent(''); setLoading(false); return; }
|
if (!relPath) { setContent(''); setLoading(false); return; }
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const url = await proxyUrl(bucket, relPath);
|
const url = await proxyUrl(bucket, relPath);
|
||||||
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Box, CircularProgress, IconButton } from '@mui/material';
|
import { Box, CircularProgress, IconButton } from '@mui/material';
|
||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
|
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
|
||||||
|
|
||||||
@ -43,6 +43,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
sectionRange?: { start: number; end: number };
|
sectionRange?: { start: number; end: number };
|
||||||
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
|
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
|
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
|
||||||
@ -184,7 +185,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||||
try {
|
try {
|
||||||
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (mRes.ok) {
|
if (mRes.ok) {
|
||||||
const m: PageImagesManifest = await mRes.json();
|
const m: PageImagesManifest = await mRes.json();
|
||||||
@ -199,7 +200,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
|
|
||||||
// Legacy: Load artefacts for file to find docling JSON artefacts
|
// Legacy: Load artefacts for file to find docling JSON artefacts
|
||||||
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
|
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
|
||||||
const artefacts: Artefact[] = await artefactsRes.json();
|
const artefacts: Artefact[] = await artefactsRes.json();
|
||||||
@ -216,7 +217,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
|
|
||||||
// Download artefact JSON via backend (service-role) to avoid RLS issues
|
// Download artefact JSON via backend (service-role) to avoid RLS issues
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, {
|
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!jsonRes.ok) throw new Error(await jsonRes.text());
|
if (!jsonRes.ok) throw new Error(await jsonRes.text());
|
||||||
const doc: DoclingJson = await jsonRes.json();
|
const doc: DoclingJson = await jsonRes.json();
|
||||||
@ -267,7 +268,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
setPageObjectUrl(cached);
|
setPageObjectUrl(cached);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
|
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok && manifest) {
|
if (!resp.ok && manifest) {
|
||||||
// Fallback to thumbnail if the full image is not accessible yet
|
// Fallback to thumbnail if the full image is not accessible yet
|
||||||
@ -369,6 +370,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
export default CCDoclingViewer;
|
export default CCDoclingViewer;
|
||||||
|
|
||||||
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -376,7 +378,7 @@ const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
|||||||
let revoked: string | null = null;
|
let revoked: string | null = null;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { SelectChangeEvent } from '@mui/material/Select';
|
|||||||
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
|
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
|
||||||
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
|
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
|
||||||
import CCBundleViewer from './CCBundleViewer.tsx';
|
import CCBundleViewer from './CCBundleViewer.tsx';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type CanonicalDoclingConfig = {
|
type CanonicalDoclingConfig = {
|
||||||
pipeline: 'standard' | 'vlm' | 'asr';
|
pipeline: 'standard' | 'vlm' | 'asr';
|
||||||
@ -46,6 +46,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const { fileId } = useParams<{ fileId: string }>();
|
const { fileId } = useParams<{ fileId: string }>();
|
||||||
|
|
||||||
const validFileId = useMemo(() => fileId || '', [fileId]);
|
const validFileId = useMemo(() => fileId || '', [fileId]);
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
|
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
|
||||||
const [profile, setProfile] = useState<Profile>('default');
|
const [profile, setProfile] = useState<Profile>('default');
|
||||||
@ -148,7 +149,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const loadBundles = async () => {
|
const loadBundles = async () => {
|
||||||
if (!validFileId) return;
|
if (!validFileId) return;
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const arts: Artefact[] = await res.json();
|
const arts: Artefact[] = await res.json();
|
||||||
@ -225,14 +226,14 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||||
try {
|
try {
|
||||||
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
|
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!artsRes.ok) return;
|
if (!artsRes.ok) return;
|
||||||
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
|
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
|
||||||
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
||||||
if (!outlineArt) return;
|
if (!outlineArt) return;
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!jsonRes.ok) return;
|
if (!jsonRes.ok) return;
|
||||||
const doc = await jsonRes.json();
|
const doc = await jsonRes.json();
|
||||||
@ -242,7 +243,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const splitArt = arts.find(a => a.type === 'split_map_json');
|
const splitArt = arts.find(a => a.type === 'split_map_json');
|
||||||
if (splitArt) {
|
if (splitArt) {
|
||||||
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
|
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (smRes.ok) {
|
if (smRes.ok) {
|
||||||
const sm = await smRes.json();
|
const sm = await smRes.json();
|
||||||
@ -486,7 +487,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const body: CanonicalDoclingRequest = {
|
const body: CanonicalDoclingRequest = {
|
||||||
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
|
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@ -11,7 +11,8 @@ import Schedule from '@mui/icons-material/Schedule';
|
|||||||
import Visibility from '@mui/icons-material/Visibility';
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
import Psychology from '@mui/icons-material/Psychology';
|
import Psychology from '@mui/icons-material/Psychology';
|
||||||
import Overview from '@mui/icons-material/Home';
|
import Overview from '@mui/icons-material/Home';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import CalendarViewMonth from '@mui/icons-material/CalendarViewMonth';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type PageImagesManifest = {
|
type PageImagesManifest = {
|
||||||
@ -66,6 +67,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
fileId, selectedPage, onSelectPage, currentSection
|
fileId, selectedPage, onSelectPage, currentSection
|
||||||
}) => {
|
}) => {
|
||||||
// State
|
// State
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||||
@ -94,7 +96,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
|
|
||||||
// Load page images manifest
|
// Load page images manifest
|
||||||
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||||
@ -198,7 +200,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
|
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type PageImagesManifest = {
|
type PageImagesManifest = {
|
||||||
version: number;
|
version: number;
|
||||||
@ -54,6 +54,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
selectedPage: number;
|
selectedPage: number;
|
||||||
onSelectPage: (p: number) => void;
|
onSelectPage: (p: number) => void;
|
||||||
}> = ({ fileId, selectedPage, onSelectPage }) => {
|
}> = ({ fileId, selectedPage, onSelectPage }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||||
@ -73,7 +74,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!mRes.ok) throw new Error(await mRes.text());
|
if (!mRes.ok) throw new Error(await mRes.text());
|
||||||
const m: PageImagesManifest = await mRes.json();
|
const m: PageImagesManifest = await mRes.json();
|
||||||
@ -82,14 +83,14 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
// Try to load outline structure artefact (for grouping only)
|
// Try to load outline structure artefact (for grouping only)
|
||||||
try {
|
try {
|
||||||
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (artsRes.ok) {
|
if (artsRes.ok) {
|
||||||
const arts: Array<{ id: string; type: string }> = await artsRes.json();
|
const arts: Array<{ id: string; type: string }> = await artsRes.json();
|
||||||
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
||||||
if (outlineArt) {
|
if (outlineArt) {
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (jsonRes.ok) {
|
if (jsonRes.ok) {
|
||||||
const outJson = await jsonRes.json();
|
const outJson = await jsonRes.json();
|
||||||
@ -118,7 +119,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
const pg = manifest.page_images[idx];
|
const pg = manifest.page_images[idx];
|
||||||
if (!pg) return undefined;
|
if (!pg) return undefined;
|
||||||
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
|
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok) return undefined;
|
if (!resp.ok) return undefined;
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
@ -150,7 +151,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
<IconButton size="small" onClick={async () => {
|
<IconButton size="small" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setShowAdmin(true);
|
setShowAdmin(true);
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAdminData(data);
|
setAdminData(data);
|
||||||
|
|||||||
@ -10,11 +10,11 @@ import {
|
|||||||
TLStoreWithStatus
|
TLStoreWithStatus
|
||||||
} from '@tldraw/tldraw';
|
} from '@tldraw/tldraw';
|
||||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useUser } from '../../contexts/UserContext';
|
import { useUser } from '../../contexts/UserContext';
|
||||||
// Tldraw services
|
// Tldraw services
|
||||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||||
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
|
|
||||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
||||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||||
// Tldraw utils
|
// Tldraw utils
|
||||||
@ -46,6 +46,8 @@ interface LoadingState {
|
|||||||
export default function SinglePlayerPage() {
|
export default function SinglePlayerPage() {
|
||||||
// Context hooks with initialization states
|
// Context hooks with initialization states
|
||||||
const { profile: user, loading: userLoading } = useUser();
|
const { profile: user, loading: userLoading } = useUser();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const { context, setAuthInfo, switchContext } = useNavigationStore();
|
||||||
const {
|
const {
|
||||||
tldrawPreferences,
|
tldrawPreferences,
|
||||||
initializePreferences,
|
initializePreferences,
|
||||||
@ -55,8 +57,6 @@ export default function SinglePlayerPage() {
|
|||||||
const routerNavigate = useNavigate();
|
const routerNavigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Navigation store
|
|
||||||
const { context } = useNavigationStore();
|
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
@ -114,6 +114,7 @@ export default function SinglePlayerPage() {
|
|||||||
|
|
||||||
// 2. Initialize snapshot service
|
// 2. Initialize snapshot service
|
||||||
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
||||||
|
if (accessToken) snapshotService.setAccessToken(accessToken);
|
||||||
snapshotServiceRef.current = snapshotService;
|
snapshotServiceRef.current = snapshotService;
|
||||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
||||||
|
|
||||||
@ -122,7 +123,7 @@ export default function SinglePlayerPage() {
|
|||||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
const nodeStoragePath = getNodeStoragePath(context.node);
|
||||||
if (nodeStoragePath) {
|
if (nodeStoragePath) {
|
||||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
||||||
dbName: user.user_db_name,
|
dbName: null,
|
||||||
node: context.node,
|
node: context.node,
|
||||||
node_storage_path: nodeStoragePath,
|
node_storage_path: nodeStoragePath,
|
||||||
user_type: user.user_type,
|
user_type: user.user_type,
|
||||||
@ -131,12 +132,14 @@ export default function SinglePlayerPage() {
|
|||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
nodeStoragePath,
|
nodeStoragePath,
|
||||||
user.user_db_name,
|
accessToken || '',
|
||||||
newStore,
|
newStore,
|
||||||
setLoadingState,
|
setLoadingState,
|
||||||
undefined, // sharedStore
|
undefined,
|
||||||
editorRef.current || undefined // editor
|
editorRef.current || undefined
|
||||||
);
|
);
|
||||||
|
// Wire auto-save: set the current path on the service instance
|
||||||
|
snapshotService.setCurrentNodePath(nodeStoragePath);
|
||||||
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
||||||
} else {
|
} else {
|
||||||
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
|
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
|
||||||
@ -152,7 +155,7 @@ export default function SinglePlayerPage() {
|
|||||||
let isAutoSaving = false;
|
let isAutoSaving = false;
|
||||||
|
|
||||||
newStore.listen(() => {
|
newStore.listen(() => {
|
||||||
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) {
|
if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) {
|
||||||
// Skip if already saving
|
// Skip if already saving
|
||||||
if (isAutoSaving) {
|
if (isAutoSaving) {
|
||||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
|
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
|
||||||
@ -178,8 +181,6 @@ export default function SinglePlayerPage() {
|
|||||||
isAutoSaving = false;
|
isAutoSaving = false;
|
||||||
}
|
}
|
||||||
}, 2000); // Increased to 2 seconds debounce
|
}, 2000); // Increased to 2 seconds debounce
|
||||||
} else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) {
|
|
||||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -253,11 +254,16 @@ export default function SinglePlayerPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
setLoadingState({ status: 'loading', error: '' });
|
||||||
|
|
||||||
// Center the node
|
if (context.node.type !== 'workspace') {
|
||||||
const nodeData = await loadNodeData(context.node);
|
try {
|
||||||
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
const nodeData = await loadNodeData(context.node);
|
||||||
|
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
||||||
|
} catch (shapeErr) {
|
||||||
|
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: context.node.type, error: shapeErr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsInitialLoad(false);
|
setIsInitialLoad(false);
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -297,12 +303,17 @@ export default function SinglePlayerPage() {
|
|||||||
? context.history.nodes[context.history.currentIndex - 1]
|
? context.history.nodes[context.history.currentIndex - 1]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Handle navigation in snapshot service
|
// Handle navigation in snapshot service (load/save snapshot)
|
||||||
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
||||||
|
|
||||||
// Center the node on canvas
|
if (currentNode.type !== 'workspace') {
|
||||||
const nodeData = await loadNodeData(currentNode);
|
try {
|
||||||
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
const nodeData = await loadNodeData(currentNode);
|
||||||
|
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
||||||
|
} catch (shapeErr) {
|
||||||
|
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: currentNode.type, error: shapeErr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -315,7 +326,17 @@ export default function SinglePlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleNodeChange();
|
handleNodeChange();
|
||||||
}, [context.node, context.history, store, isInitialLoad]);
|
}, [context.node, context.history, store]);
|
||||||
|
|
||||||
|
// Inject auth and trigger initial context when token is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.id && accessToken) {
|
||||||
|
setAuthInfo(accessToken, user.id);
|
||||||
|
if (!context.node) {
|
||||||
|
switchContext({ main: 'profile', base: 'profile' }, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user?.id, accessToken]);
|
||||||
|
|
||||||
// Initialize preferences when user is available
|
// Initialize preferences when user is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -462,9 +483,6 @@ export default function SinglePlayerPage() {
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
top: `${HEADER_HEIGHT}px`,
|
top: `${HEADER_HEIGHT}px`,
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
}}>
|
||||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||||
{(loadingState.status === 'loading' || !store) && (
|
{(loadingState.status === 'loading' || !store) && (
|
||||||
@ -527,6 +545,7 @@ export default function SinglePlayerPage() {
|
|||||||
// Update snapshot service with editor reference
|
// Update snapshot service with editor reference
|
||||||
if (snapshotServiceRef.current) {
|
if (snapshotServiceRef.current) {
|
||||||
snapshotServiceRef.current.setEditor(editor);
|
snapshotServiceRef.current.setEditor(editor);
|
||||||
|
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditorReady(true);
|
setIsEditorReady(true);
|
||||||
@ -565,68 +584,21 @@ const getNodeStoragePath = (node: NavigationNode): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
||||||
// Validate the node parameter
|
if (!node?.id) throw new Error('Node parameter is required');
|
||||||
if (!node) {
|
|
||||||
throw new Error('Node parameter is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node.id) {
|
|
||||||
throw new Error('Node must have an ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeStoragePath = getNodeStoragePath(node);
|
const nodeStoragePath = getNodeStoragePath(node);
|
||||||
if (!nodeStoragePath) {
|
if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`);
|
||||||
throw new Error(`Node ${node.id} is missing node_storage_path`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
nodeLabel: node.label,
|
|
||||||
nodeStoragePath: nodeStoragePath
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
const theme = getThemeFromLabel(node.type);
|
||||||
// 1. Always fetch fresh data
|
return {
|
||||||
// Create a temporary node object with the correct structure for the service
|
title: node.label || node.type || '',
|
||||||
const normalizedNode = {
|
w: 500,
|
||||||
...node,
|
h: 350,
|
||||||
node_storage_path: nodeStoragePath
|
state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null },
|
||||||
};
|
headerColor: theme.headerColor,
|
||||||
|
backgroundColor: theme.backgroundColor,
|
||||||
const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
|
isLocked: false,
|
||||||
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
|
__primarylabel__: node.type,
|
||||||
|
uuid_string: node.id,
|
||||||
if (!fetchedData?.node_data) {
|
node_storage_path: nodeStoragePath,
|
||||||
throw new Error('Failed to fetch node data');
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Process the data into the correct shape
|
|
||||||
const theme = getThemeFromLabel(node.type);
|
|
||||||
return {
|
|
||||||
...fetchedData.node_data,
|
|
||||||
title: String(fetchedData.node_data.title || node.label || ''),
|
|
||||||
w: 500,
|
|
||||||
h: 350,
|
|
||||||
state: {
|
|
||||||
parentId: null,
|
|
||||||
isPageChild: true,
|
|
||||||
hasChildren: null,
|
|
||||||
bindings: null
|
|
||||||
},
|
|
||||||
headerColor: theme.headerColor,
|
|
||||||
backgroundColor: theme.backgroundColor,
|
|
||||||
isLocked: false,
|
|
||||||
__primarylabel__: node.type,
|
|
||||||
uuid_string: node.id,
|
|
||||||
node_storage_path: nodeStoragePath
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('single-player-page', '❌ Error in loadNodeData', {
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { TLUserPreferences } from '@tldraw/tldraw';
|
|||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import { storageService, StorageKeys } from './localStorageService';
|
import { storageService, StorageKeys } from './localStorageService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { DatabaseNameService } from '../graph/databaseNameService';
|
|
||||||
|
|
||||||
export interface CCUser {
|
export interface CCUser {
|
||||||
id: string;
|
id: string;
|
||||||
@ -11,8 +10,7 @@ export interface CCUser {
|
|||||||
user_type: string;
|
user_type: string;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
user_db_name: string;
|
school_id?: string | null;
|
||||||
school_db_name: string;
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@ -44,28 +42,13 @@ export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
|
|||||||
// Default to student if no user type specified
|
// Default to student if no user type specified
|
||||||
const userType = metadata.user_type || 'student';
|
const userType = metadata.user_type || 'student';
|
||||||
|
|
||||||
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
|
|
||||||
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
|
||||||
|
|
||||||
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(
|
|
||||||
userType,
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
const schoolDbName = metadata.school_db_name || metadata.worker_db_name || storedSchoolDb || '';
|
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName,
|
|
||||||
schoolDbName
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
user_type: userType,
|
user_type: userType,
|
||||||
username: username,
|
username,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
user_db_name: userDbName,
|
school_id: null,
|
||||||
school_db_name: schoolDbName,
|
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
updated_at: user.updated_at,
|
updated_at: user.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { RegistrationResponse } from '../../services/auth/authService';
|
|||||||
import { storageService, StorageKeys } from './localStorageService';
|
import { storageService, StorageKeys } from './localStorageService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { provisionUser } from '../provisioningService';
|
import { provisionUser } from '../provisioningService';
|
||||||
import { DatabaseNameService } from '../graph/databaseNameService';
|
|
||||||
|
|
||||||
const REGISTRATION_SERVICE = 'registration-service';
|
const REGISTRATION_SERVICE = 'registration-service';
|
||||||
|
|
||||||
@ -87,14 +86,6 @@ export class RegistrationService {
|
|||||||
try {
|
try {
|
||||||
const provisioned = await provisionUser(ccUser.id, provisioningToken);
|
const provisioned = await provisionUser(ccUser.id, provisioningToken);
|
||||||
if (provisioned) {
|
if (provisioned) {
|
||||||
ccUser.user_db_name = provisioned.user_db_name;
|
|
||||||
if (provisioned.worker_db_name) {
|
|
||||||
ccUser.school_db_name = provisioned.worker_db_name;
|
|
||||||
}
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: ccUser.user_db_name,
|
|
||||||
schoolDbName: ccUser.school_db_name
|
|
||||||
});
|
|
||||||
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
|
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
|
||||||
userId: ccUser.id,
|
userId: ccUser.id,
|
||||||
userDbName: provisioned.user_db_name,
|
userDbName: provisioned.user_db_name,
|
||||||
@ -110,11 +101,6 @@ export class RegistrationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: ccUser.user_db_name,
|
|
||||||
schoolDbName: ccUser.school_db_name
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: ccUser,
|
user: ccUser,
|
||||||
accessToken: authData.session?.access_token || null,
|
accessToken: authData.session?.access_token || null,
|
||||||
|
|||||||
@ -1,27 +1,52 @@
|
|||||||
// External imports
|
// External imports
|
||||||
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
||||||
import axios from '../../axiosConfig';
|
|
||||||
import logger from '../../debugConfig';
|
import logger from '../../debugConfig';
|
||||||
import { SharedStoreService } from './sharedStoreService';
|
import { SharedStoreService } from './sharedStoreService';
|
||||||
import { StorageKeys, storageService } from '../auth/localStorageService';
|
|
||||||
import { NavigationNode } from '../../types/navigation';
|
|
||||||
|
|
||||||
export interface LoadingState {
|
export interface LoadingState {
|
||||||
status: 'loading' | 'ready' | 'error';
|
status: 'loading' | 'ready' | 'error';
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_NODE: NavigationNode = {
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
|
||||||
id: '',
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
|
||||||
node_storage_path: '',
|
const BUCKET = 'cc.users';
|
||||||
type: '',
|
|
||||||
label: ''
|
async function storageGet(path: string, accessToken: string): Promise<unknown | null> {
|
||||||
};
|
const url = `${SUPABASE_URL}/storage/v1/object/authenticated/${BUCKET}/${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 404 || res.status === 400) return null;
|
||||||
|
if (!res.ok) throw new Error(`Storage GET ${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storagePut(path: string, accessToken: string, data: unknown): Promise<void> {
|
||||||
|
const url = `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${path}`;
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
const body = JSON.stringify(data);
|
||||||
|
// PUT replaces an existing object; POST creates a new one.
|
||||||
|
// Avoids x-upsert custom header which self-hosted Supabase CORS may block.
|
||||||
|
let res = await fetch(url, { method: 'PUT', headers, body });
|
||||||
|
if (!res.ok && (res.status === 404 || res.status === 400)) {
|
||||||
|
res = await fetch(url, { method: 'POST', headers, body });
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`Storage ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export class NavigationSnapshotService {
|
export class NavigationSnapshotService {
|
||||||
private store: TLStore;
|
private store: TLStore;
|
||||||
private editor: Editor | null = null;
|
private editor: Editor | null = null;
|
||||||
private currentNodePath: string | null = null;
|
private currentNodePath: string | null = null;
|
||||||
|
private _accessToken: string | null = null;
|
||||||
private isAutoSaveEnabled = true;
|
private isAutoSaveEnabled = true;
|
||||||
private isSaving = false;
|
private isSaving = false;
|
||||||
private isLoading = false;
|
private isLoading = false;
|
||||||
@ -33,24 +58,21 @@ export class NavigationSnapshotService {
|
|||||||
this.editor = editor || null;
|
this.editor = editor || null;
|
||||||
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
|
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
hasEditor: !!editor
|
hasEditor: !!editor,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditor(editor: Editor): void {
|
setEditor(editor: Editor): void {
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
logger.debug('snapshot-service', '🔄 Editor reference updated', {
|
|
||||||
editorId: editor.store.id
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static replaceBackslashes(input: string | undefined): string {
|
setAccessToken(token: string): void {
|
||||||
return input ? input.replace(/\\/g, '/') : '';
|
this._accessToken = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async loadNodeSnapshotFromDatabase(
|
static async loadNodeSnapshotFromDatabase(
|
||||||
nodePath: string,
|
nodePath: string,
|
||||||
dbName: string,
|
accessToken: string,
|
||||||
store: TLStore,
|
store: TLStore,
|
||||||
setLoadingState: (state: LoadingState) => void,
|
setLoadingState: (state: LoadingState) => void,
|
||||||
sharedStore?: SharedStoreService,
|
sharedStore?: SharedStoreService,
|
||||||
@ -58,248 +80,102 @@ export class NavigationSnapshotService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
setLoadingState({ status: 'loading', error: '' });
|
||||||
|
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
|
||||||
|
|
||||||
logger.info('snapshot-service', '📂 Loading file from path', {
|
const snapshot = await storageGet(nodePath, accessToken);
|
||||||
path: nodePath,
|
|
||||||
db_name: dbName
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await axios.get(
|
if (!snapshot) {
|
||||||
'/database/tldraw_supabase/get_tldraw_node_file', {
|
logger.debug('snapshot-service', 'ℹ️ No snapshot found at path — clearing canvas', { nodePath });
|
||||||
params: {
|
// Clear all shapes so the canvas is blank for this new node
|
||||||
path: this.replaceBackslashes(nodePath),
|
if (editor) {
|
||||||
db_name: dbName
|
const shapeIds = [...editor.getCurrentPageShapeIds()];
|
||||||
|
if (shapeIds.length > 0) {
|
||||||
|
editor.deleteShapes(shapeIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
|
return;
|
||||||
const snapshot = response.data;
|
|
||||||
logger.debug('snapshot-service', '🔍 Snapshot data received', {
|
|
||||||
hasSnapshot: !!snapshot,
|
|
||||||
hasDocument: !!snapshot?.document,
|
|
||||||
hasSession: !!snapshot?.session,
|
|
||||||
hasSchemaVersion: !!snapshot?.schemaVersion,
|
|
||||||
schemaVersion: snapshot?.schemaVersion,
|
|
||||||
snapshotKeys: snapshot ? Object.keys(snapshot) : []
|
|
||||||
});
|
|
||||||
|
|
||||||
if (snapshot && snapshot.document && snapshot.session) {
|
|
||||||
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
|
|
||||||
|
|
||||||
if (sharedStore) {
|
|
||||||
await sharedStore.loadSnapshot(snapshot, setLoadingState);
|
|
||||||
} else {
|
|
||||||
logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', {
|
|
||||||
hasStore: !!store,
|
|
||||||
snapshotType: typeof snapshot,
|
|
||||||
snapshotKeys: Object.keys(snapshot),
|
|
||||||
snapshotSchemaVersion: snapshot?.schemaVersion,
|
|
||||||
snapshotDocument: !!snapshot?.document,
|
|
||||||
snapshotSession: !!snapshot?.session
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a defensive copy to ensure the snapshot doesn't get modified
|
|
||||||
const snapshotCopy = {
|
|
||||||
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion,
|
|
||||||
document: snapshot.document,
|
|
||||||
session: snapshot.session
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '🔄 Calling loadSnapshot with defensive copy', {
|
|
||||||
copySchemaVersion: snapshotCopy.schemaVersion,
|
|
||||||
copyDocument: !!snapshotCopy.document,
|
|
||||||
copySession: !!snapshotCopy.session,
|
|
||||||
storeType: typeof store,
|
|
||||||
storeIsNull: store === null,
|
|
||||||
storeIsUndefined: store === undefined,
|
|
||||||
storeKeys: store ? Object.keys(store) : 'N/A'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug: Log the snapshot schema sequences
|
|
||||||
if (snapshotCopy.document?.schema?.sequences) {
|
|
||||||
logger.debug('snapshot-service', '🔍 Snapshot schema sequences:', snapshotCopy.document.schema.sequences);
|
|
||||||
const customSequences = Object.keys(snapshotCopy.document.schema.sequences).filter(key => key.includes('cc-'));
|
|
||||||
logger.debug('snapshot-service', '🔍 Custom shape sequences in snapshot:', customSequences);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: Log the store schema sequences
|
|
||||||
if (store?.schema) {
|
|
||||||
const storeSequences = store.schema.serialize().sequences;
|
|
||||||
logger.debug('snapshot-service', '🔍 Store schema sequences:', storeSequences);
|
|
||||||
const storeCustomSequences = Object.keys(storeSequences).filter(key => key.includes('cc-'));
|
|
||||||
logger.debug('snapshot-service', '🔍 Custom shape sequences in store:', storeCustomSequences);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add try-catch around the loadSnapshot call to get more specific error info
|
|
||||||
try {
|
|
||||||
// Ensure store is properly initialized before loading snapshot
|
|
||||||
if (!store) {
|
|
||||||
throw new Error('Store is null or undefined');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate snapshot structure before loading
|
|
||||||
if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) {
|
|
||||||
throw new Error('Invalid snapshot structure');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for schema migrations and handle them properly
|
|
||||||
logger.debug('snapshot-service', '🔄 Checking for schema migrations', {
|
|
||||||
storeId: store.id,
|
|
||||||
storeType: typeof store,
|
|
||||||
storeConstructor: store.constructor.name,
|
|
||||||
snapshotSchemaVersion: snapshotCopy.schemaVersion,
|
|
||||||
snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}),
|
|
||||||
snapshotSessionKeys: Object.keys(snapshotCopy.session || {})
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to load the snapshot directly first
|
|
||||||
logger.debug('snapshot-service', '🔄 Attempting to load snapshot directly');
|
|
||||||
if (editor) {
|
|
||||||
loadSnapshot(editor.store, snapshotCopy);
|
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
|
||||||
} else {
|
|
||||||
// Fallback: use global loadSnapshot if no editor available
|
|
||||||
logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot');
|
|
||||||
loadSnapshot(store, snapshotCopy);
|
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully via global loadSnapshot');
|
|
||||||
}
|
|
||||||
} catch (migrationError) {
|
|
||||||
// Check if this is a schema migration error that we can safely ignore
|
|
||||||
const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError);
|
|
||||||
const isSchemaMigrationError = errorMessage.includes('migration') ||
|
|
||||||
errorMessage.includes('schema') ||
|
|
||||||
errorMessage.includes('Incompatible');
|
|
||||||
|
|
||||||
if (isSchemaMigrationError) {
|
|
||||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', {
|
|
||||||
error: errorMessage
|
|
||||||
});
|
|
||||||
// Continue with empty store - this is expected for some snapshots
|
|
||||||
} else {
|
|
||||||
logger.warn('snapshot-service', '⚠️ Unexpected load error', {
|
|
||||||
error: errorMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '✅ loadSnapshot call succeeded');
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
|
||||||
} catch (loadError) {
|
|
||||||
logger.error('snapshot-service', '❌ loadSnapshot call failed', {
|
|
||||||
error: loadError instanceof Error ? loadError.message : String(loadError),
|
|
||||||
storeType: typeof store,
|
|
||||||
storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function',
|
|
||||||
snapshotType: typeof snapshotCopy,
|
|
||||||
snapshotKeys: Object.keys(snapshotCopy)
|
|
||||||
});
|
|
||||||
throw loadError;
|
|
||||||
}
|
|
||||||
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error('snapshot-service', '❌ Invalid snapshot format');
|
|
||||||
setLoadingState({ status: 'error', error: 'Invalid snapshot format' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown };
|
||||||
|
if (!snap.document || !snap.session) {
|
||||||
|
logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath });
|
||||||
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedStore) {
|
||||||
|
await sharedStore.loadSnapshot(snapshot, setLoadingState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotCopy = {
|
||||||
|
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
||||||
|
document: snap.document,
|
||||||
|
session: snap.session,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editor) {
|
||||||
|
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||||
|
} else {
|
||||||
|
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||||
|
}
|
||||||
|
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const isSchemaMigration = /migration|schema|Incompatible/i.test(msg);
|
||||||
|
if (isSchemaMigration) {
|
||||||
|
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
||||||
|
} else {
|
||||||
|
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
|
logger.error('snapshot-service', '❌ Failed to load snapshot', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
setLoadingState({
|
setLoadingState({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: error instanceof Error ? error.message : 'Failed to load file'
|
error: error instanceof Error ? error.message : 'Failed to load snapshot',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async saveNodeSnapshotToDatabase(
|
static async saveNodeSnapshotToDatabase(
|
||||||
nodePath: string,
|
nodePath: string,
|
||||||
dbName: string,
|
accessToken: string,
|
||||||
store: TLStore
|
store: TLStore
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info('snapshot-service', '💾 Saving snapshot to database', {
|
logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
|
||||||
path: nodePath,
|
|
||||||
db_name: dbName
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = getSnapshot(store);
|
const snapshot = getSnapshot(store);
|
||||||
|
await storagePut(nodePath, accessToken, snapshot);
|
||||||
// Debug: Log what we're saving
|
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
|
||||||
logger.debug('snapshot-service', '🔍 Snapshot being saved:', {
|
|
||||||
hasSnapshot: !!snapshot,
|
|
||||||
snapshotKeys: Object.keys(snapshot || {}),
|
|
||||||
schemaVersion: snapshot?.schemaVersion,
|
|
||||||
hasDocument: !!snapshot?.document,
|
|
||||||
hasSession: !!snapshot?.session
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug: Log the schema sequences in the snapshot being saved
|
|
||||||
if (snapshot?.document?.schema?.sequences) {
|
|
||||||
logger.debug('snapshot-service', '🔍 Schema sequences being saved:', snapshot.document.schema.sequences);
|
|
||||||
const customSequences = Object.keys(snapshot.document.schema.sequences).filter(key => key.includes('cc-'));
|
|
||||||
logger.debug('snapshot-service', '🔍 Custom shape sequences being saved:', customSequences);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
'/database/tldraw_supabase/set_tldraw_node_file',
|
|
||||||
snapshot,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
path: this.replaceBackslashes(nodePath),
|
|
||||||
db_name: dbName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data.status === 'success') {
|
|
||||||
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to save snapshot');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Failed to save snapshot', {
|
logger.error('snapshot-service', '❌ Failed to save snapshot', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
|
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
|
||||||
if (!this.currentNodePath || this.currentNodePath !== nodePath) {
|
if (!this.currentNodePath || this.currentNodePath !== nodePath) return;
|
||||||
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
|
if (!this._accessToken) {
|
||||||
currentPath: this.currentNodePath,
|
logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped');
|
||||||
savePath: nodePath
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
const user = storageService.get(StorageKeys.USER);
|
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store);
|
||||||
if (!user) {
|
logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath });
|
||||||
throw new Error('No user found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbName = user.user_db_name;
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '💾 Saving snapshot', {
|
|
||||||
nodePath,
|
|
||||||
dbName,
|
|
||||||
userType: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store);
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '✅ Saved navigation snapshot', {
|
|
||||||
nodePath,
|
|
||||||
storeId: this.store.id
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
|
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
nodePath
|
nodePath,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@ -307,137 +183,77 @@ export class NavigationSnapshotService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSnapshotForNode(node: NavigationNode): Promise<void> {
|
private async loadSnapshotForNode(node: { node_storage_path: string }): Promise<void> {
|
||||||
|
if (!this._accessToken) {
|
||||||
|
logger.debug('snapshot-service', '⚠️ No access token — snapshot load skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const user = storageService.get(StorageKeys.USER);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('No user found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbName = user.user_db_name;
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '📥 Loading snapshot', {
|
|
||||||
nodePath: node.node_storage_path,
|
|
||||||
dbName,
|
|
||||||
userType: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
node.node_storage_path,
|
node.node_storage_path,
|
||||||
dbName,
|
this._accessToken,
|
||||||
this.store,
|
this.store,
|
||||||
(state: LoadingState) => {
|
(state: LoadingState) => {
|
||||||
if (state.status === 'ready') {
|
if (state.status === 'ready') {
|
||||||
this.currentNodePath = node.node_storage_path;
|
this.currentNodePath = node.node_storage_path;
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
|
|
||||||
nodePath: node.node_storage_path,
|
|
||||||
currentNodePath: this.currentNodePath
|
|
||||||
});
|
|
||||||
} else if (state.status === 'error') {
|
|
||||||
logger.error('snapshot-service', '❌ Error in load callback', {
|
|
||||||
error: state.error,
|
|
||||||
nodePath: node.node_storage_path
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined, // sharedStore
|
undefined,
|
||||||
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot
|
this.editor || undefined
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
nodePath: node.node_storage_path
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
|
async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> {
|
||||||
if (!toNode) {
|
if (!toNode) return;
|
||||||
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node');
|
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any pending debounce
|
|
||||||
if (this.debounceTimeout) {
|
|
||||||
clearTimeout(this.debounceTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce the navigation operation
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.debounceTimeout = setTimeout(async () => {
|
this.debounceTimeout = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await this.executeNavigation(fromNode || EMPTY_NODE, toNode);
|
await this.executeNavigation(fromNode, toNode);
|
||||||
resolve();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Navigation failed', error);
|
logger.error('snapshot-service', '❌ Navigation failed', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, 100); // 100ms debounce
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
|
private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> {
|
||||||
try {
|
if (this.isSaving || this.isLoading) {
|
||||||
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
|
this.pendingOperation = {
|
||||||
from: fromNode.node_storage_path,
|
save: fromNode?.node_storage_path,
|
||||||
to: toNode.node_storage_path,
|
load: toNode.node_storage_path,
|
||||||
currentPath: this.currentNodePath
|
};
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If we're already in a navigation operation, queue this one
|
this.currentNodePath = null;
|
||||||
if (this.isSaving || this.isLoading) {
|
|
||||||
this.pendingOperation = {
|
|
||||||
save: fromNode.node_storage_path || undefined,
|
|
||||||
load: toNode.node_storage_path
|
|
||||||
};
|
|
||||||
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the store before loading new snapshot
|
if (toNode.node_storage_path) {
|
||||||
logger.debug('snapshot-service', '🔄 Clearing store');
|
await this.loadSnapshotForNode(toNode);
|
||||||
this.currentNodePath = null;
|
}
|
||||||
logger.debug('snapshot-service', '🧹 Cleared current node path');
|
|
||||||
|
|
||||||
// Load the new node's snapshot
|
if (this.pendingOperation) {
|
||||||
if (toNode.node_storage_path) {
|
const op = this.pendingOperation;
|
||||||
await this.loadSnapshotForNode(toNode);
|
this.pendingOperation = null;
|
||||||
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
|
await this.handleNavigationStart(
|
||||||
nodePath: toNode.node_storage_path
|
op.save ? { node_storage_path: op.save } : null,
|
||||||
});
|
op.load ? { node_storage_path: op.load } : null
|
||||||
}
|
);
|
||||||
|
|
||||||
// Process any pending operations
|
|
||||||
if (this.pendingOperation) {
|
|
||||||
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation);
|
|
||||||
const operation = this.pendingOperation;
|
|
||||||
this.pendingOperation = null;
|
|
||||||
await this.handleNavigationStart(
|
|
||||||
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null,
|
|
||||||
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null
|
|
||||||
);
|
|
||||||
logger.debug('snapshot-service', '✅ Completed pending operation');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
fromPath: fromNode.node_storage_path,
|
|
||||||
toPath: toNode.node_storage_path
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoSave(enabled: boolean): void {
|
setAutoSave(enabled: boolean): void {
|
||||||
this.isAutoSaveEnabled = enabled;
|
this.isAutoSaveEnabled = enabled;
|
||||||
logger.debug('snapshot-service', '🔄 Auto-save setting changed', {
|
}
|
||||||
enabled
|
|
||||||
});
|
setCurrentNodePath(path: string): void {
|
||||||
|
this.currentNodePath = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentNodePath(): string | null {
|
getCurrentNodePath(): string | null {
|
||||||
@ -447,14 +263,11 @@ export class NavigationSnapshotService {
|
|||||||
async forceSaveCurrentNode(): Promise<void> {
|
async forceSaveCurrentNode(): Promise<void> {
|
||||||
if (this.currentNodePath) {
|
if (this.currentNodePath) {
|
||||||
await this.saveCurrentSnapshot(this.currentNodePath);
|
await this.saveCurrentSnapshot(this.currentNodePath);
|
||||||
} else {
|
|
||||||
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCurrentNode(): void {
|
clearCurrentNode(): void {
|
||||||
this.currentNodePath = null;
|
this.currentNodePath = null;
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
logger.debug('snapshot-service', '🧹 Cleared current node and store');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,45 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { UserNeoDBService } from '../services/graph/userNeoDBService';
|
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||||
import {
|
import {
|
||||||
NavigationStore,
|
NavigationStore,
|
||||||
NavigationNode,
|
NavigationNode,
|
||||||
|
NeoGraphNode,
|
||||||
MainContext,
|
MainContext,
|
||||||
BaseContext,
|
BaseContext,
|
||||||
NavigationContextState,
|
NavigationContextState,
|
||||||
isProfileContext,
|
isProfileContext,
|
||||||
isInstituteContext,
|
isInstituteContext,
|
||||||
getContextDatabase,
|
|
||||||
addToHistory,
|
addToHistory,
|
||||||
navigateHistory,
|
navigateHistory,
|
||||||
getCurrentHistoryNode,
|
getCurrentHistoryNode,
|
||||||
ExtendedContext,
|
ExtendedContext,
|
||||||
UnifiedContextSwitch,
|
UnifiedContextSwitch,
|
||||||
NodeContext
|
|
||||||
} from '../types/navigation';
|
} from '../types/navigation';
|
||||||
|
|
||||||
|
interface WhiteboardRoom {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
context_type: string;
|
||||||
|
is_default: boolean;
|
||||||
|
storage_path: string | null;
|
||||||
|
neo4j_node_id: string | null;
|
||||||
|
neo4j_db_name: string | null;
|
||||||
|
node_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationStoreWithAuth extends NavigationStore {
|
||||||
|
_accessToken: string | null;
|
||||||
|
_userId: string | null;
|
||||||
|
setAuthInfo: (token: string | null, userId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: NavigationContextState = {
|
const initialState: NavigationContextState = {
|
||||||
main: 'profile',
|
main: 'profile',
|
||||||
base: 'profile',
|
base: 'profile',
|
||||||
node: null,
|
node: null,
|
||||||
history: {
|
history: { nodes: [], currentIndex: -1 }
|
||||||
nodes: [],
|
|
||||||
currentIndex: -1
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDefaultBaseForMain(main: MainContext): BaseContext {
|
function getDefaultBaseForMain(main: MainContext): BaseContext {
|
||||||
@ -38,402 +51,288 @@ function validateContextTransition(
|
|||||||
updates: Partial<NavigationContextState>
|
updates: Partial<NavigationContextState>
|
||||||
): NavigationContextState {
|
): NavigationContextState {
|
||||||
const newState = { ...current, ...updates };
|
const newState = { ...current, ...updates };
|
||||||
|
|
||||||
// Validate main context
|
|
||||||
if (updates.main) {
|
if (updates.main) {
|
||||||
newState.base = getDefaultBaseForMain(updates.main);
|
newState.base = getDefaultBaseForMain(updates.main);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate base context
|
|
||||||
if (updates.base) {
|
if (updates.base) {
|
||||||
// Ensure base context matches main context
|
const isValid = newState.main === 'profile'
|
||||||
const isValid = newState.main === 'profile'
|
|
||||||
? isProfileContext(updates.base)
|
? isProfileContext(updates.base)
|
||||||
: isInstituteContext(updates.base);
|
: isInstituteContext(updates.base);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
newState.base = getDefaultBaseForMain(newState.main);
|
newState.base = getDefaultBaseForMain(newState.main);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationActions {
|
export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
|
||||||
// Context Navigation
|
const pgFetch = async <T = unknown>(
|
||||||
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
table: string,
|
||||||
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
|
||||||
|
): Promise<T | null> => {
|
||||||
// Node Navigation
|
const token = get()._accessToken;
|
||||||
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
if (!token) throw new Error('pgFetch: no access token');
|
||||||
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
// History Navigation
|
'Authorization': `Bearer ${token}`,
|
||||||
goBack: () => void;
|
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
goForward: () => void;
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
// Utility Methods
|
if (options.prefer) headers['Prefer'] = options.prefer;
|
||||||
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json';
|
||||||
}
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`PostgREST ${res.status}: ${err}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
export interface NavigationState {
|
const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
|
||||||
context: {
|
const userId = get()._userId;
|
||||||
main: NodeContext;
|
if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
|
||||||
base: NodeContext;
|
|
||||||
extended?: string;
|
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
node: NavigationNode;
|
query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`,
|
||||||
history: {
|
});
|
||||||
nodes: NavigationNode[];
|
|
||||||
currentIndex: number;
|
if (rooms && rooms.length > 0) {
|
||||||
|
const room = rooms[0];
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`,
|
||||||
|
label: room.name,
|
||||||
|
type: 'workspace',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePath = `${userId}/workspaces/${contextType}_default.json`;
|
||||||
|
const room = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
||||||
|
body: {
|
||||||
|
user_id: userId,
|
||||||
|
name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`,
|
||||||
|
context_type: contextType,
|
||||||
|
is_default: true,
|
||||||
|
storage_path: storagePath,
|
||||||
|
},
|
||||||
|
prefer: 'return=representation',
|
||||||
|
single: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) throw new Error('Failed to create default whiteboard room');
|
||||||
|
logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || storagePath,
|
||||||
|
label: room.name,
|
||||||
|
type: 'workspace',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
// ... rest of the state interface ...
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
return {
|
||||||
context: initialState,
|
_accessToken: null,
|
||||||
isLoading: false,
|
_userId: null,
|
||||||
error: null,
|
setAuthInfo: (token: string | null, userId: string | null) => {
|
||||||
|
set({ _accessToken: token, _userId: userId });
|
||||||
|
},
|
||||||
|
|
||||||
switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => {
|
context: initialState,
|
||||||
try {
|
isLoading: false,
|
||||||
// Check if we have the necessary database connections
|
error: null,
|
||||||
if (contextSwitch.main === 'profile' && !userDbName) {
|
|
||||||
logger.error('navigation-context', '❌ User database connection not initialized');
|
switchContext: async (contextSwitch: UnifiedContextSwitch, _userDbName: string | null = null, _workerDbName: string | null = null) => {
|
||||||
set({
|
if (!get()._accessToken || !get()._userId) {
|
||||||
error: 'User database connection not initialized',
|
logger.warn('navigation-context', '⚠️ switchContext called without auth — skipping');
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (contextSwitch.main === 'institute' && !workerDbName) {
|
|
||||||
logger.error('navigation-context', '❌ Worker database connection not initialized');
|
|
||||||
set({
|
|
||||||
error: 'Worker database connection not initialized',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const currentState = get().context;
|
||||||
|
let newState: NavigationContextState = { ...currentState, node: null };
|
||||||
|
|
||||||
logger.debug('navigation-context', '🔄 Starting context switch', {
|
if (contextSwitch.main) {
|
||||||
from: {
|
newState = validateContextTransition(newState, { main: contextSwitch.main });
|
||||||
main: get().context.main,
|
if (!contextSwitch.skipBaseContextLoad) {
|
||||||
base: get().context.base,
|
newState.base = getDefaultBaseForMain(contextSwitch.main);
|
||||||
extended: contextSwitch.extended,
|
}
|
||||||
nodeId: get().context.node?.id
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
main: contextSwitch.main,
|
|
||||||
base: contextSwitch.base,
|
|
||||||
extended: contextSwitch.extended
|
|
||||||
},
|
|
||||||
skipBaseContextLoad: contextSwitch.skipBaseContextLoad
|
|
||||||
});
|
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
|
|
||||||
const currentState = get().context;
|
|
||||||
|
|
||||||
// Clear node state immediately
|
|
||||||
const clearedState: NavigationContextState = {
|
|
||||||
...currentState,
|
|
||||||
node: null
|
|
||||||
};
|
|
||||||
set({
|
|
||||||
context: clearedState,
|
|
||||||
isLoading: true
|
|
||||||
});
|
|
||||||
|
|
||||||
let newState: NavigationContextState = {
|
|
||||||
...currentState,
|
|
||||||
node: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update main context if provided
|
|
||||||
if (contextSwitch.main) {
|
|
||||||
newState = validateContextTransition(newState, { main: contextSwitch.main });
|
|
||||||
if (!contextSwitch.skipBaseContextLoad) {
|
|
||||||
newState.base = getDefaultBaseForMain(contextSwitch.main);
|
|
||||||
}
|
}
|
||||||
logger.debug('navigation-state', '✅ Main context updated', {
|
if (contextSwitch.base) {
|
||||||
previous: currentState.main,
|
newState = validateContextTransition(newState, { base: contextSwitch.base });
|
||||||
new: newState.main,
|
|
||||||
defaultBase: newState.base
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update base context if provided
|
|
||||||
if (contextSwitch.base) {
|
|
||||||
newState = validateContextTransition(newState, { base: contextSwitch.base });
|
|
||||||
logger.debug('navigation-state', '✅ Base context updated', {
|
|
||||||
previous: currentState.base,
|
|
||||||
new: newState.base
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('navigation-state', '✅ Context validation complete', {
|
|
||||||
validatedState: newState,
|
|
||||||
originalState: currentState
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine which context to use for the node
|
|
||||||
const targetContext = contextSwitch.base ||
|
|
||||||
contextSwitch.extended ||
|
|
||||||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) :
|
|
||||||
newState.base);
|
|
||||||
|
|
||||||
// Get database name
|
|
||||||
const dbName = getContextDatabase(newState, userDbName, workerDbName);
|
|
||||||
|
|
||||||
logger.debug('context-switch', '🔍 Fetching default node for context', {
|
|
||||||
targetContext,
|
|
||||||
dbName,
|
|
||||||
currentState: newState
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get default node for the final context
|
|
||||||
const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName);
|
|
||||||
|
|
||||||
if (!defaultNode) {
|
|
||||||
const errorMsg = `No default node found for context: ${targetContext}`;
|
|
||||||
logger.error('context-switch', '❌ Default node fetch failed', { targetContext });
|
|
||||||
set({
|
|
||||||
error: errorMsg,
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('context-switch', '✨ Default node fetched', {
|
|
||||||
nodeId: defaultNode.id,
|
|
||||||
node_storage_path: defaultNode.node_storage_path,
|
|
||||||
type: defaultNode.type
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update history and state
|
|
||||||
const newHistory = addToHistory(currentState.history, defaultNode);
|
|
||||||
logger.debug('history-management', '📚 History updated', {
|
|
||||||
previousState: currentState.history,
|
|
||||||
newState: newHistory,
|
|
||||||
addedNode: defaultNode
|
|
||||||
});
|
|
||||||
|
|
||||||
set({
|
|
||||||
context: {
|
|
||||||
...newState,
|
|
||||||
node: defaultNode,
|
|
||||||
history: newHistory
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('navigation-context', '✅ Context switch completed', {
|
|
||||||
finalState: {
|
|
||||||
main: newState.main,
|
|
||||||
base: newState.base,
|
|
||||||
nodeId: defaultNode.id
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation-context', '❌ Failed to switch context:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to switch context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
goBack: () => {
|
const targetContext = contextSwitch.base ||
|
||||||
const currentState = get().context;
|
contextSwitch.extended ||
|
||||||
if (currentState.history.currentIndex > 0) {
|
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
|
||||||
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
|
|
||||||
const node = getCurrentHistoryNode(newHistory);
|
|
||||||
set({
|
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
goForward: () => {
|
const defaultNode = await getOrCreateDefaultRoom(targetContext);
|
||||||
const currentState = get().context;
|
const newHistory = addToHistory(currentState.history, defaultNode);
|
||||||
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
|
|
||||||
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
|
|
||||||
const node = getCurrentHistoryNode(newHistory);
|
|
||||||
set({
|
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
|
|
||||||
try {
|
|
||||||
// Use switchContext instead of direct implementation
|
|
||||||
await get().switchContext({ main }, userDbName, workerDbName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to set main context:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to set main context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
|
|
||||||
try {
|
|
||||||
// Use switchContext instead of direct implementation
|
|
||||||
await get().switchContext({ base }, userDbName, workerDbName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to set base context:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to set base context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
|
|
||||||
try {
|
|
||||||
// Use switchContext instead of direct implementation
|
|
||||||
await get().switchContext({ extended }, userDbName, workerDbName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to set extended context:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to set extended context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
navigate: async (nodeId: string, dbName: string) => {
|
|
||||||
try {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
|
|
||||||
// Check if we already have this node in history
|
|
||||||
const currentState = get().context;
|
|
||||||
const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
|
|
||||||
|
|
||||||
// If node exists in history, just navigate to it
|
|
||||||
if (existingNodeIndex !== -1) {
|
|
||||||
logger.debug('navigation', '📍 Navigating to existing node in history', {
|
|
||||||
nodeId,
|
|
||||||
historyIndex: existingNodeIndex,
|
|
||||||
currentIndex: currentState.history.currentIndex
|
|
||||||
});
|
|
||||||
|
|
||||||
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
|
|
||||||
const node = getCurrentHistoryNode(newHistory);
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
context: {
|
context: { ...newState, node: defaultNode, history: newHistory },
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null
|
error: null,
|
||||||
});
|
});
|
||||||
|
logger.debug('navigation-context', '✅ Context switch complete', {
|
||||||
|
main: newState.main, base: newState.base, nodeId: defaultNode.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('navigation-context', '❌ switchContext failed', error);
|
||||||
|
set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goBack: () => {
|
||||||
|
const currentState = get().context;
|
||||||
|
if (currentState.history.currentIndex > 0) {
|
||||||
|
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
|
||||||
|
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goForward: () => {
|
||||||
|
const currentState = get().context;
|
||||||
|
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
|
||||||
|
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
|
||||||
|
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
|
||||||
|
await get().switchContext({ main }, userDbName, workerDbName);
|
||||||
|
},
|
||||||
|
|
||||||
|
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
|
||||||
|
await get().switchContext({ base }, userDbName, workerDbName);
|
||||||
|
},
|
||||||
|
|
||||||
|
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
|
||||||
|
await get().switchContext({ extended }, userDbName, workerDbName);
|
||||||
|
},
|
||||||
|
|
||||||
|
navigate: async (nodeId: string, _dbName: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
if (!get()._accessToken) { set({ isLoading: false }); return; }
|
||||||
|
|
||||||
|
const currentState = get().context;
|
||||||
|
const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
const newHistory = navigateHistory(currentState.history, existingIndex);
|
||||||
|
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
|
query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`,
|
||||||
|
});
|
||||||
|
if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`);
|
||||||
|
|
||||||
|
const room = rooms[0];
|
||||||
|
const node: NavigationNode = {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`,
|
||||||
|
label: room.name,
|
||||||
|
type: 'workspace',
|
||||||
|
};
|
||||||
|
const newHistory = addToHistory(currentState.history, node);
|
||||||
|
set({ context: { ...currentState, node, history: newHistory }, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('navigation', '❌ navigate failed', error);
|
||||||
|
set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
|
||||||
|
if (!isValidNodeType(node.type)) {
|
||||||
|
logger.warn('navigation', `⚠️ navigateToNode called with non-graph type: ${node.type} — navigating anyway`);
|
||||||
|
}
|
||||||
|
await get().navigate(node.id, userDbName || '');
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshNavigationState: async (_userDbName: string | null, _workerDbName: string | null) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const currentNode = get().context.node;
|
||||||
|
if (currentNode && get()._accessToken) {
|
||||||
|
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
|
query: `id=eq.${currentNode.id}`,
|
||||||
|
});
|
||||||
|
if (rooms && rooms.length > 0) {
|
||||||
|
const room = rooms[0];
|
||||||
|
set({
|
||||||
|
context: {
|
||||||
|
...get().context,
|
||||||
|
node: {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || currentNode.node_storage_path,
|
||||||
|
label: room.name,
|
||||||
|
type: 'workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('navigation', '❌ refreshNavigationState failed', error);
|
||||||
|
set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateToNeoNode: async (neoNode: NeoGraphNode) => {
|
||||||
|
const userId = get()._userId;
|
||||||
|
if (!userId || !get()._accessToken) {
|
||||||
|
logger.warn('navigation', '⚠️ navigateToNeoNode called without auth');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
// Fetch new node data
|
set({ isLoading: true, error: null });
|
||||||
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName);
|
const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
if (!nodeData) {
|
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`,
|
||||||
throw new Error(`Node not found: ${nodeId}`);
|
});
|
||||||
}
|
let room: WhiteboardRoom;
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
const node: NavigationNode = {
|
room = existing[0];
|
||||||
id: nodeId,
|
} else {
|
||||||
node_storage_path: nodeData.node_data.node_storage_path || '',
|
const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`;
|
||||||
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
|
const created = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
||||||
type: nodeData.node_type
|
body: {
|
||||||
};
|
user_id: userId,
|
||||||
|
name: neoNode.label,
|
||||||
logger.debug('navigation', '📍 Adding new node to history', {
|
context_type: neoNode.node_type.toLowerCase(),
|
||||||
nodeId: node.id,
|
is_default: false,
|
||||||
type: node.type,
|
storage_path: storagePath,
|
||||||
node_storage_path: node.node_storage_path
|
neo4j_node_id: neoNode.neo4j_node_id,
|
||||||
});
|
neo4j_db_name: neoNode.neo4j_db_name,
|
||||||
|
node_type: neoNode.node_type,
|
||||||
// Add to history and update state
|
},
|
||||||
const newHistory = addToHistory(currentState.history, node);
|
prefer: 'return=representation',
|
||||||
set({
|
single: true,
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to navigate:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to navigate',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
|
|
||||||
try {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
|
|
||||||
if (!isValidNodeType(node.type)) {
|
|
||||||
throw new Error(`Invalid node type: ${node.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbName = getContextDatabase(get().context, userDbName, workerDbName);
|
|
||||||
await get().navigate(node.id, dbName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to navigate to node:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to navigate to node',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => {
|
|
||||||
try {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
const currentState = get().context;
|
|
||||||
|
|
||||||
if (currentState.node) {
|
|
||||||
const dbName = getContextDatabase(currentState, userDbName, workerDbName);
|
|
||||||
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName);
|
|
||||||
if (nodeData) {
|
|
||||||
const node: NavigationNode = {
|
|
||||||
id: currentState.node.id,
|
|
||||||
node_storage_path: nodeData.node_data.node_storage_path || '',
|
|
||||||
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
|
|
||||||
type: nodeData.node_type
|
|
||||||
};
|
|
||||||
set({
|
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if (!created) throw new Error('Failed to create whiteboard room for node');
|
||||||
|
room = created;
|
||||||
}
|
}
|
||||||
|
const node: NavigationNode = {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`,
|
||||||
|
label: room.name,
|
||||||
|
type: neoNode.node_type,
|
||||||
|
};
|
||||||
|
const currentState = get().context;
|
||||||
|
const newHistory = addToHistory(currentState.history, node);
|
||||||
|
set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null });
|
||||||
|
logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('navigation', '❌ navigateToNeoNode failed', error);
|
||||||
|
set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
set({ isLoading: false });
|
};
|
||||||
} catch (error) {
|
});
|
||||||
logger.error('navigation', '❌ Failed to refresh navigation state:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to refresh navigation state',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -227,6 +227,13 @@ export interface UnifiedContextSwitch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigation Actions Interface
|
// Navigation Actions Interface
|
||||||
|
export interface NeoGraphNode {
|
||||||
|
neo4j_node_id: string;
|
||||||
|
neo4j_db_name: string;
|
||||||
|
node_type: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NavigationActions {
|
export interface NavigationActions {
|
||||||
// Unified Context Switch
|
// Unified Context Switch
|
||||||
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||||
@ -239,6 +246,7 @@ export interface NavigationActions {
|
|||||||
// Node Navigation
|
// Node Navigation
|
||||||
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
||||||
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||||
|
navigateToNeoNode: (neoNode: NeoGraphNode) => Promise<void>;
|
||||||
|
|
||||||
// History Navigation
|
// History Navigation
|
||||||
goBack: () => void;
|
goBack: () => void;
|
||||||
|
|||||||
@ -7,6 +7,14 @@ export interface TranscriptionConfig {
|
|||||||
useVad?: boolean;
|
useVad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerSegment {
|
||||||
|
text: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerSegmentsCallback = (segments: ServerSegment[], isLastLive: boolean) => void;
|
||||||
|
|
||||||
export class TranscriptionService {
|
export class TranscriptionService {
|
||||||
private socket: WebSocket | null = null;
|
private socket: WebSocket | null = null;
|
||||||
private stream: MediaStream | null = null;
|
private stream: MediaStream | null = null;
|
||||||
@ -14,27 +22,29 @@ export class TranscriptionService {
|
|||||||
private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||||||
private workletNode: AudioWorkletNode | null = null;
|
private workletNode: AudioWorkletNode | null = null;
|
||||||
private selectedDeviceId: string = '';
|
private selectedDeviceId: string = '';
|
||||||
private finalizedSegmentCount: number = 0;
|
private intentionalStop: boolean = false;
|
||||||
private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null;
|
private onServerSegments: ServerSegmentsCallback | null = null;
|
||||||
|
private onDisconnect: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(deviceId: string = '') {
|
constructor(deviceId: string = '') {
|
||||||
this.selectedDeviceId = deviceId;
|
this.selectedDeviceId = deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) {
|
setServerSegmentsCallback(callback: ServerSegmentsCallback) {
|
||||||
this.onTranscriptionUpdate = callback;
|
this.onServerSegments = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisconnectCallback(callback: () => void) {
|
||||||
|
this.onDisconnect = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startTranscription(config: TranscriptionConfig = {}) {
|
async startTranscription(config: TranscriptionConfig = {}) {
|
||||||
console.log('🎙️ Starting transcription service...');
|
console.log('🎙️ Starting transcription service...');
|
||||||
|
this.intentionalStop = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info('transcription-service', '🔊 Requesting microphone access...');
|
logger.info('transcription-service', '🔊 Requesting microphone access...');
|
||||||
|
|
||||||
// Call getUserMedia directly — this triggers the browser permission prompt.
|
|
||||||
// The old code called enumerateDevices() first to find a device ID, but
|
|
||||||
// without microphone permission deviceId is always (empty string, falsy),
|
|
||||||
// causing an early return that never prompted the user for permission.
|
|
||||||
const audioConstraints: MediaTrackConstraints = this.selectedDeviceId
|
const audioConstraints: MediaTrackConstraints = this.selectedDeviceId
|
||||||
? { deviceId: { exact: this.selectedDeviceId } }
|
? { deviceId: { exact: this.selectedDeviceId } }
|
||||||
: { echoCancellation: true, noiseSuppression: true };
|
: { echoCancellation: true, noiseSuppression: true };
|
||||||
@ -60,13 +70,13 @@ export class TranscriptionService {
|
|||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
logger.info('transcription-service', '✅ WebSocket connected');
|
logger.info('transcription-service', '✅ WebSocket connected');
|
||||||
|
|
||||||
// Send initial configuration — audio capture starts only after SERVER_READY.
|
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
uid: uuid,
|
uid: uuid,
|
||||||
language: config.language || 'en',
|
language: config.language || 'en',
|
||||||
task: config.task || 'transcribe',
|
task: config.task || 'transcribe',
|
||||||
model: config.modelSize || 'base',
|
model: config.modelSize || 'large-v3',
|
||||||
use_vad: config.useVad ?? true,
|
use_vad: config.useVad ?? true,
|
||||||
|
max_connection_time: 7200, // server default is 600 s — set to 2 h
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -76,17 +86,18 @@ export class TranscriptionService {
|
|||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
logger.info('transcription-service', '🔌 WebSocket closed');
|
logger.info('transcription-service', '🔌 WebSocket closed');
|
||||||
|
const wasIntentional = this.intentionalStop;
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
|
if (!wasIntentional && this.onDisconnect) {
|
||||||
|
this.onDisconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.uid !== uuid) {
|
if (data.uid !== uuid) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.message === 'SERVER_READY') {
|
if (data.message === 'SERVER_READY') {
|
||||||
// Server is ready — now safe to start streaming audio.
|
|
||||||
logger.info('transcription-service', '🟢 Server ready, starting audio capture');
|
logger.info('transcription-service', '🟢 Server ready, starting audio capture');
|
||||||
this.setupAudioProcessing();
|
this.setupAudioProcessing();
|
||||||
return;
|
return;
|
||||||
@ -94,37 +105,29 @@ export class TranscriptionService {
|
|||||||
|
|
||||||
if (data.status === 'WAIT') {
|
if (data.status === 'WAIT') {
|
||||||
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
|
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
|
||||||
|
this.intentionalStop = true;
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.message === 'DISCONNECT') {
|
if (data.message === 'DISCONNECT') {
|
||||||
logger.info('transcription-service', '🔕 Server requested disconnection');
|
logger.info('transcription-service', '🔕 Server requested disconnection');
|
||||||
|
this.intentionalStop = true;
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) {
|
// Pass the full segment window directly to the store — the store owns
|
||||||
const segments = data.segments;
|
// all boundary and archival decisions, matching the WhisperLive reference
|
||||||
const lastIdx = segments.length - 1;
|
// frontend which simply re-renders the server's authoritative segment list.
|
||||||
|
if (this.onServerSegments && data.segments && data.segments.length > 0) {
|
||||||
// Only emit segments we have not finalized yet — avoids re-processing the
|
const segs: ServerSegment[] = data.segments.map((s: any) => ({
|
||||||
// full array on every message (which caused the stuck last segment bug).
|
text: String(s.text ?? ''),
|
||||||
for (let i = this.finalizedSegmentCount; i < lastIdx; i++) {
|
start: parseFloat(s.start ?? 0),
|
||||||
const seg = segments[i];
|
end: parseFloat(s.end ?? 0),
|
||||||
this.onTranscriptionUpdate(seg.text, true, {
|
}));
|
||||||
start: parseFloat(seg.start),
|
const isLastLive = !(data.segments[data.segments.length - 1]?.completed);
|
||||||
end: parseFloat(seg.end),
|
this.onServerSegments(segs, isLastLive);
|
||||||
});
|
|
||||||
this.finalizedSegmentCount = i + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always update the live (last) segment
|
|
||||||
const lastSeg = segments[lastIdx];
|
|
||||||
this.onTranscriptionUpdate(lastSeg.text, lastSeg.completed ?? false, {
|
|
||||||
start: parseFloat(lastSeg.start),
|
|
||||||
end: parseFloat(lastSeg.end),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -134,26 +137,18 @@ export class TranscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setupAudioProcessing() {
|
private async setupAudioProcessing() {
|
||||||
if (!this.stream || !this.socket) {
|
if (!this.stream || !this.socket) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request 16 kHz from the browser — it resamples natively so we send
|
|
||||||
// the correct rate to the server without any JS resampling overhead.
|
|
||||||
this.audioContext = new AudioContext({ sampleRate: 16000 });
|
this.audioContext = new AudioContext({ sampleRate: 16000 });
|
||||||
|
|
||||||
await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
|
await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
|
||||||
|
|
||||||
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
|
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
|
||||||
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
|
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
|
||||||
|
|
||||||
// The worklet accumulates 4096 samples (256 ms at 16 kHz) before posting,
|
|
||||||
// matching the reference frontend chunk size and eliminating the tiny-frame
|
|
||||||
// flood that was overwhelming the server during silence.
|
|
||||||
this.workletNode.port.onmessage = (event) => {
|
this.workletNode.port.onmessage = (event) => {
|
||||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||||
this.socket.send(event.data); // event.data is a transferred ArrayBuffer
|
this.socket.send(event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -165,7 +160,7 @@ export class TranscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stopTranscription() {
|
stopTranscription() {
|
||||||
// Signal the server cleanly so it can finalise the last segment.
|
this.intentionalStop = true;
|
||||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||||
this.socket.send('END_OF_AUDIO');
|
this.socket.send('END_OF_AUDIO');
|
||||||
}
|
}
|
||||||
@ -173,27 +168,22 @@ export class TranscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cleanup() {
|
private cleanup() {
|
||||||
this.finalizedSegmentCount = 0;
|
|
||||||
if (this.workletNode) {
|
if (this.workletNode) {
|
||||||
this.workletNode.disconnect();
|
this.workletNode.disconnect();
|
||||||
this.workletNode = null;
|
this.workletNode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.mediaStreamSource) {
|
if (this.mediaStreamSource) {
|
||||||
this.mediaStreamSource.disconnect();
|
this.mediaStreamSource.disconnect();
|
||||||
this.mediaStreamSource = null;
|
this.mediaStreamSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.audioContext) {
|
if (this.audioContext) {
|
||||||
this.audioContext.close();
|
this.audioContext.close();
|
||||||
this.audioContext = null;
|
this.audioContext = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.stream) {
|
if (this.stream) {
|
||||||
this.stream.getTracks().forEach(track => track.stop());
|
this.stream.getTracks().forEach(track => track.stop());
|
||||||
this.stream = null;
|
this.stream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { CCYoutubePanel } from './CCYoutubePanel';
|
|||||||
import { CCGraphPanel } from './CCGraphPanel';
|
import { CCGraphPanel } from './CCGraphPanel';
|
||||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
||||||
import { CCSearchPanel } from './CCSearchPanel'
|
import { CCSearchPanel } from './CCSearchPanel'
|
||||||
|
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'
|
||||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
|
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
|
||||||
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
||||||
import './panel.css';
|
import './panel.css';
|
||||||
@ -145,7 +146,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
return createTheme({
|
return createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode,
|
mode,
|
||||||
divider: 'var(--color-divider)',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||||
@ -281,6 +281,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
return <CCGraphPanel />;
|
return <CCGraphPanel />;
|
||||||
case 'search':
|
case 'search':
|
||||||
return <CCSearchPanel />;
|
return <CCSearchPanel />;
|
||||||
|
case 'navigation':
|
||||||
|
return <CCGraphNavPanel />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -386,9 +388,11 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
<div className="panel-content">
|
<div className="panel-content">
|
||||||
{renderCurrentPanel()}
|
{renderCurrentPanel()}
|
||||||
</div>
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ThemeProvider, createTheme, useMediaQuery, Box, Grid, Card, CardContent, CardActions, Typography, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, styled } from '@mui/material';
|
import { ThemeProvider, createTheme, useMediaQuery, Box, Grid, Card, CardContent, CardActions, Typography, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, styled } from '@mui/material';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
||||||
import { supabase } from '../../../../../supabaseClient';
|
import { useAuth } from '../../../../../contexts/AuthContext';
|
||||||
|
|
||||||
type Cabinet = { id: string; name: string };
|
type Cabinet = { id: string; name: string };
|
||||||
|
|
||||||
const Toolbar = styled('div')(() => ({ display: 'flex', gap: '8px', marginBottom: '8px' }));
|
const Toolbar = styled('div')(() => ({ display: 'flex', gap: '8px', marginBottom: '8px' }));
|
||||||
|
|
||||||
export const CCCabinetsPanel: React.FC = () => {
|
export const CCCabinetsPanel: React.FC = () => {
|
||||||
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
|
const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } };
|
||||||
|
const { user: authUser, accessToken } = useAuth();
|
||||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@ -28,27 +29,29 @@ export const CCCabinetsPanel: React.FC = () => {
|
|||||||
const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||||
|
|
||||||
type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record<string, string> } | undefined;
|
type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record<string, string> } | undefined;
|
||||||
const apiFetch = async (url: string, init?: RequestInitLite) => {
|
const apiFetch = useCallback(async (url: string, init?: RequestInitLite) => {
|
||||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
const bearer = session?.access_token || authToken || '';
|
|
||||||
const res = await fetch(fullUrl, {
|
const res = await fetch(fullUrl, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${bearer}`,
|
'Authorization': `Bearer ${accessToken || ''}`,
|
||||||
...(init?.headers || {})
|
...(init?.headers || {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
}, [accessToken, API_BASE]);
|
||||||
|
|
||||||
const loadCabinets = async () => {
|
const loadCabinets = useCallback(async () => {
|
||||||
const data = await apiFetch('/database/cabinets');
|
const data = await apiFetch('/database/cabinets');
|
||||||
setCabinets([...(data.owned || []), ...(data.shared || [])]);
|
setCabinets([...(data.owned || []), ...(data.shared || [])]);
|
||||||
};
|
}, [apiFetch]);
|
||||||
|
|
||||||
useEffect(() => { loadCabinets(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, []);
|
useEffect(() => {
|
||||||
|
if (authUser?.id) {
|
||||||
|
loadCabinets();
|
||||||
|
}
|
||||||
|
}, [loadCabinets, authUser?.id]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
|
|||||||
@ -40,7 +40,7 @@ import ImageIcon from '@mui/icons-material/Image';
|
|||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||||
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
||||||
import { supabase } from '../../../../../supabaseClient';
|
import { useAuth } from '../../../../../contexts/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
calculateDirectoryStats,
|
calculateDirectoryStats,
|
||||||
@ -92,7 +92,8 @@ interface FileListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CCFilesPanel: React.FC = () => {
|
export const CCFilesPanel: React.FC = () => {
|
||||||
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
|
const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } };
|
||||||
|
const { user: authUser, accessToken } = useAuth();
|
||||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
||||||
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
|
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
|
||||||
@ -115,6 +116,7 @@ export const CCFilesPanel: React.FC = () => {
|
|||||||
const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([
|
const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([
|
||||||
{ id: null, name: 'Root' }
|
{ id: null, name: 'Root' }
|
||||||
]);
|
]);
|
||||||
|
const initialSelectionDone = useRef(false);
|
||||||
|
|
||||||
// Directory upload state
|
// Directory upload state
|
||||||
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
|
||||||
@ -143,14 +145,14 @@ export const CCFilesPanel: React.FC = () => {
|
|||||||
|
|
||||||
const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => {
|
const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => {
|
||||||
const headers: HeadersInitLike = {
|
const headers: HeadersInitLike = {
|
||||||
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`,
|
'Authorization': `Bearer ${accessToken || ''}`,
|
||||||
...(init?.headers || {})
|
...(init?.headers || {})
|
||||||
};
|
};
|
||||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
||||||
const res = await fetch(fullUrl, { ...(init || {}), headers });
|
const res = await fetch(fullUrl, { ...(init || {}), headers });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
return res.json();
|
||||||
}, [authToken, API_BASE]);
|
}, [accessToken, API_BASE]);
|
||||||
|
|
||||||
const loadCabinets = useCallback(async () => {
|
const loadCabinets = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -158,13 +160,16 @@ export const CCFilesPanel: React.FC = () => {
|
|||||||
const data = await apiFetch('/database/cabinets');
|
const data = await apiFetch('/database/cabinets');
|
||||||
const all = [...(data.owned || []), ...(data.shared || [])];
|
const all = [...(data.owned || []), ...(data.shared || [])];
|
||||||
setCabinets(all);
|
setCabinets(all);
|
||||||
if (all.length && !selectedCabinet) setSelectedCabinet(all[0].id);
|
if (all.length && !initialSelectionDone.current) {
|
||||||
|
initialSelectionDone.current = true;
|
||||||
|
setSelectedCabinet(all[0].id);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load cabinets:', error);
|
console.error('Failed to load cabinets:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedCabinet, apiFetch]);
|
}, [apiFetch]);
|
||||||
|
|
||||||
const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => {
|
const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => {
|
||||||
if (!cabinetId) return;
|
if (!cabinetId) return;
|
||||||
@ -203,8 +208,11 @@ export const CCFilesPanel: React.FC = () => {
|
|||||||
}, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]);
|
}, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCabinets();
|
if (authUser?.id) {
|
||||||
}, [loadCabinets]);
|
initialSelectionDone.current = false;
|
||||||
|
loadCabinets();
|
||||||
|
}
|
||||||
|
}, [loadCabinets, authUser?.id]);
|
||||||
|
|
||||||
// Main loading effect - handles pagination, sorting, cabinet changes, directory navigation
|
// Main loading effect - handles pagination, sorting, cabinet changes, directory navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import DescriptionIcon from '@mui/icons-material/Description';
|
|||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
||||||
import { supabase } from '../../../../../supabaseClient';
|
import { useAuth } from '../../../../../contexts/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
@ -75,7 +75,8 @@ interface UploadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CCFilesPanelEnhanced: React.FC = () => {
|
export const CCFilesPanelEnhanced: React.FC = () => {
|
||||||
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
|
const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } };
|
||||||
|
const { user: authUser, accessToken } = useAuth();
|
||||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
||||||
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
|
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
|
||||||
@ -109,7 +110,7 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
|||||||
|
|
||||||
const apiFetch = async (url: string, init?: RequestInitLike) => {
|
const apiFetch = async (url: string, init?: RequestInitLike) => {
|
||||||
const headers: HeadersInitLike = {
|
const headers: HeadersInitLike = {
|
||||||
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`,
|
'Authorization': `Bearer ${accessToken || ''}`,
|
||||||
...(init?.headers || {})
|
...(init?.headers || {})
|
||||||
};
|
};
|
||||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
||||||
@ -140,7 +141,7 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadCabinets(); }, []);
|
useEffect(() => { if (authUser?.id) { loadCabinets(); } }, [authUser?.id]);
|
||||||
useEffect(() => { if (selectedCabinet) loadFiles(selectedCabinet); }, [selectedCabinet]);
|
useEffect(() => { if (selectedCabinet) loadFiles(selectedCabinet); }, [selectedCabinet]);
|
||||||
|
|
||||||
const handleSingleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSingleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useAuth } from "../../../../../contexts/AuthContext";
|
||||||
import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material";
|
import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material";
|
||||||
import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch } from "../../../../../stores/transcriptionStore";
|
import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch, ServerSegment } from "../../../../../stores/transcriptionStore";
|
||||||
import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService";
|
import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService";
|
||||||
import { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger";
|
import { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger";
|
||||||
import LLMConfigModal from "./LLMConfigModal";
|
import LLMConfigModal from "./LLMConfigModal";
|
||||||
@ -17,6 +18,26 @@ const formatDateTime = (isoString: string): string => {
|
|||||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatSrtTime = (seconds: number): string => {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
const ms = Math.round((seconds % 1) * 1000);
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBlob = (content: string, filename: string, mimeType: string) => {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
};
|
||||||
|
|
||||||
type TabType = "live" | "sessions" | "keywords";
|
type TabType = "live" | "sessions" | "keywords";
|
||||||
|
|
||||||
const SUMMARY_TYPES = [
|
const SUMMARY_TYPES = [
|
||||||
@ -31,6 +52,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
const {
|
const {
|
||||||
isRecording,
|
isRecording,
|
||||||
completedSegments,
|
completedSegments,
|
||||||
|
serverWindow,
|
||||||
currentSegment,
|
currentSegment,
|
||||||
wordCount,
|
wordCount,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
@ -38,13 +60,14 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
timetableContext,
|
timetableContext,
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
saveSegment,
|
updateServerWindow,
|
||||||
resetSession,
|
resetSession,
|
||||||
tickElapsed,
|
tickElapsed,
|
||||||
addCanvasEvent,
|
addCanvasEvent,
|
||||||
flushCanvasEvents,
|
flushCanvasEvents,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
setTimetableContext,
|
setTimetableContext,
|
||||||
|
setAuthInfo,
|
||||||
llmConfig,
|
llmConfig,
|
||||||
summaryText,
|
summaryText,
|
||||||
isGeneratingSummary,
|
isGeneratingSummary,
|
||||||
@ -62,6 +85,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
} = useTranscriptionStore();
|
} = useTranscriptionStore();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("live");
|
const [activeTab, setActiveTab] = useState<TabType>("live");
|
||||||
|
const [viewMode, setViewMode] = useState<'segments' | 'transcript'>('segments');
|
||||||
const [sessions, setSessions] = useState<TranscriptionSession[]>([]);
|
const [sessions, setSessions] = useState<TranscriptionSession[]>([]);
|
||||||
const [sessionName, setSessionName] = useState("Untitled Session");
|
const [sessionName, setSessionName] = useState("Untitled Session");
|
||||||
const serviceRef = useRef<TranscriptionService | null>(null);
|
const serviceRef = useRef<TranscriptionService | null>(null);
|
||||||
@ -70,6 +94,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||||
|
const { user: authUser, accessToken } = useAuth();
|
||||||
const [showSummaryModal, setShowSummaryModal] = useState(false);
|
const [showSummaryModal, setShowSummaryModal] = useState(false);
|
||||||
const [summaryType, setSummaryType] = useState('full_lesson');
|
const [summaryType, setSummaryType] = useState('full_lesson');
|
||||||
|
|
||||||
@ -77,17 +102,25 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
const [newKeyword, setNewKeyword] = useState('');
|
const [newKeyword, setNewKeyword] = useState('');
|
||||||
const [isAddingKeyword, setIsAddingKeyword] = useState(false);
|
const [isAddingKeyword, setIsAddingKeyword] = useState(false);
|
||||||
|
|
||||||
// Load sessions and keyword watches on mount
|
// Sync access token into Zustand store so all store actions can use it without getSession()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions().then(setSessions);
|
setAuthInfo(accessToken, authUser?.id ?? null);
|
||||||
loadKeywordWatches();
|
}, [accessToken, authUser?.id, setAuthInfo]);
|
||||||
}, []);
|
|
||||||
|
// Load sessions when auth is confirmed (avoids GoTrueClient lock on mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (authUser?.id) {
|
||||||
|
loadSessions().then(setSessions);
|
||||||
|
loadKeywordWatches();
|
||||||
|
}
|
||||||
|
}, [authUser?.id]);
|
||||||
|
|
||||||
// Auto-detect timetable context on mount
|
// Auto-detect timetable context on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detectTimetable = async () => {
|
const detectTimetable = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://192.168.0.64:8000/database/timetables/current-period');
|
const apiBase = import.meta.env.VITE_API_URL || 'https://api.classroomcopilot.ai';
|
||||||
|
const response = await fetch(`${apiBase}/database/timetables/current-period`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.period_id) {
|
if (data.period_id) {
|
||||||
setTimetableContext(data as TimetablePeriod);
|
setTimetableContext(data as TimetablePeriod);
|
||||||
@ -127,14 +160,16 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await startSession(timetableContext || undefined);
|
await startSession(timetableContext || undefined);
|
||||||
const service = new TranscriptionService();
|
const service = new TranscriptionService();
|
||||||
service.setTranscriptionCallback((text, isFinal, metadata) => {
|
service.setServerSegmentsCallback((segs: ServerSegment[], isLastLive: boolean) => {
|
||||||
saveSegment(text, isFinal, metadata);
|
updateServerWindow(segs, isLastLive);
|
||||||
if (isFinal) {
|
|
||||||
const { elapsedSeconds: elapsed } = useTranscriptionStore.getState();
|
|
||||||
checkSegmentForKeywords(text, elapsed);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
await service.startTranscription();
|
service.setDisconnectCallback(() => {
|
||||||
|
console.warn('[CCTranscriptionPanel] WebSocket disconnected unexpectedly — resetting session');
|
||||||
|
serviceRef.current = null;
|
||||||
|
stopSession();
|
||||||
|
});
|
||||||
|
const whisperModel = useTranscriptionStore.getState().llmConfig.whisperModel || 'large-v3';
|
||||||
|
await service.startTranscription({ modelSize: whisperModel });
|
||||||
serviceRef.current = service;
|
serviceRef.current = service;
|
||||||
|
|
||||||
// Initialize canvas event logger if session was created
|
// Initialize canvas event logger if session was created
|
||||||
@ -176,16 +211,17 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
setSessions(loaded);
|
setSessions(loaded);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate summary handler
|
// Generate summary — calls LLM providers directly, no backend proxy needed
|
||||||
const handleGenerateSummary = async () => {
|
const handleGenerateSummary = async () => {
|
||||||
if (!activeSession) {
|
const config = useTranscriptionStore.getState().llmConfig;
|
||||||
setSummaryError("No active session to generate summary for.");
|
const allSegs = completedSegments;
|
||||||
|
|
||||||
|
if (allSegs.length === 0) {
|
||||||
|
setSummaryError("No transcription segments to summarise yet.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!config.model) {
|
||||||
const config = useTranscriptionStore.getState().llmConfig;
|
setSummaryError("Please configure an LLM model in Settings first.");
|
||||||
if (!config.apiKey) {
|
|
||||||
setSummaryError("Please configure your API key in Settings first.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,30 +229,79 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
setSummaryError(null);
|
setSummaryError(null);
|
||||||
setShowSummaryModal(false);
|
setShowSummaryModal(false);
|
||||||
|
|
||||||
try {
|
const transcript = allSegs.map(s => s.text.trim()).filter(Boolean).join(' ');
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
|
||||||
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/summaries`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
summary_type: summaryType,
|
|
||||||
provider: config.provider,
|
|
||||||
model: config.model,
|
|
||||||
api_key: config.apiKey,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const promptMap: Record<string, string> = {
|
||||||
const errorData = await response.json().catch(() => null);
|
full_lesson: `You are an assistant helping a teacher review a lesson. Below is the full transcript of a lesson. Write a concise summary covering the main topics taught, key activities, and any notable moments.\n\nTranscript:\n${transcript}`,
|
||||||
throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`);
|
questions_asked: `Below is a classroom transcript. List all the questions that were asked during the lesson, formatted as a numbered list.\n\nTranscript:\n${transcript}`,
|
||||||
|
teaching_style: `Below is a classroom transcript. Briefly describe the teaching style observed: instructional approach, student engagement techniques, pacing, and tone.\n\nTranscript:\n${transcript}`,
|
||||||
|
key_moments: `Below is a classroom transcript. Identify the 3–5 most significant moments or turning points in the lesson.\n\nTranscript:\n${transcript}`,
|
||||||
|
segment: `Summarise the following classroom transcript in 2–3 sentences.\n\nTranscript:\n${transcript}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = promptMap[summaryType] || promptMap.full_lesson;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let summaryResult = '';
|
||||||
|
|
||||||
|
if (config.provider === 'ollama') {
|
||||||
|
const base = (config.baseUrl || 'https://ollama.kevlarai.com').replace(/\/$/, '');
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (config.apiKey && config.apiKey !== 'sk-dummy') headers['Authorization'] = `Bearer ${config.apiKey}`;
|
||||||
|
const res = await fetch(`${base}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ollama error ${res.status}: ${await res.text()}`);
|
||||||
|
const d = await res.json();
|
||||||
|
summaryResult = d.message?.content ?? JSON.stringify(d);
|
||||||
|
|
||||||
|
} else if (config.provider === 'openai' || config.provider === 'openrouter') {
|
||||||
|
const base = config.provider === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://api.openai.com/v1';
|
||||||
|
const res = await fetch(`${base}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` },
|
||||||
|
body: JSON.stringify({ model: config.model, messages: [{ role: 'user', content: prompt }] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${config.provider} error ${res.status}: ${await res.text()}`);
|
||||||
|
const d = await res.json();
|
||||||
|
summaryResult = d.choices?.[0]?.message?.content ?? JSON.stringify(d);
|
||||||
|
|
||||||
|
} else if (config.provider === 'anthropic') {
|
||||||
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': config.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model: config.model, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Anthropic error ${res.status}: ${await res.text()}`);
|
||||||
|
const d = await res.json();
|
||||||
|
summaryResult = d.content?.[0]?.text ?? JSON.stringify(d);
|
||||||
|
|
||||||
|
} else if (config.provider === 'google') {
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Google error ${res.status}: ${await res.text()}`);
|
||||||
|
const d = await res.json();
|
||||||
|
summaryResult = d.candidates?.[0]?.content?.parts?.[0]?.text ?? JSON.stringify(d);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown provider: ${config.provider}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
setSummaryText(summaryResult);
|
||||||
// The API returns the summary text in the response
|
|
||||||
const summary = data.summary || data.content || data.text || JSON.stringify(data);
|
|
||||||
setSummaryText(summary);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate summary:', error);
|
console.error('Failed to generate summary:', error);
|
||||||
setSummaryError(error instanceof Error ? error.message : 'Failed to generate summary');
|
setSummaryError(error instanceof Error ? error.message : 'Failed to generate summary');
|
||||||
@ -411,38 +496,49 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Export button */}
|
{/* Export button — available whenever there are completed segments */}
|
||||||
{activeSession && (
|
{completedSegments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="panel-divider" />
|
<div className="panel-divider" />
|
||||||
<div className="panel-section">
|
<div className="panel-section">
|
||||||
<div style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)", marginBottom: "8px" }}>
|
<div style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)", marginBottom: "8px" }}>
|
||||||
Export Session
|
Export ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''})
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
{(['srt', 'txt', 'json'] as const).map((format) => (
|
{(['srt', 'txt', 'json'] as const).map((format) => (
|
||||||
<button
|
<button
|
||||||
key={format}
|
key={format}
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
try {
|
// Build the segment list from store state — always matches what's displayed.
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
const allSegs = [
|
||||||
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/export`, {
|
...completedSegments,
|
||||||
method: 'POST',
|
...(currentSegment && currentSegment.text.trim() ? [{ ...currentSegment, isFinal: true }] : []),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
];
|
||||||
body: JSON.stringify({ format }),
|
const sessionTag = activeSession?.id.slice(0, 8) ?? 'session';
|
||||||
});
|
const filename = `${sessionTag}.${format}`;
|
||||||
if (!response.ok) throw new Error('Export failed');
|
|
||||||
const blob = await response.blob();
|
if (format === 'srt') {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const content = allSegs
|
||||||
const a = document.createElement('a');
|
.filter(s => s.text.trim())
|
||||||
a.href = url;
|
.map((seg, i) =>
|
||||||
a.download = `session_${activeSession.id.slice(0,8)}_${format}`;
|
`${i + 1}\n${formatSrtTime(seg.start)} --> ${formatSrtTime(seg.end)}\n${seg.text.trim()}\n`
|
||||||
document.body.appendChild(a);
|
)
|
||||||
a.click();
|
.join('\n');
|
||||||
window.URL.revokeObjectURL(url);
|
downloadBlob(content, filename, 'text/plain');
|
||||||
document.body.removeChild(a);
|
} else if (format === 'txt') {
|
||||||
} catch (error) {
|
const content = allSegs.map(s => s.text.trim()).filter(Boolean).join('\n');
|
||||||
console.error('Export failed:', error);
|
downloadBlob(content, filename, 'text/plain');
|
||||||
|
} else {
|
||||||
|
const content = JSON.stringify({
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
segment_count: allSegs.length,
|
||||||
|
segments: allSegs.map(s => ({
|
||||||
|
start: s.start,
|
||||||
|
end: s.end,
|
||||||
|
text: s.text.trim(),
|
||||||
|
})),
|
||||||
|
}, null, 2);
|
||||||
|
downloadBlob(content, filename, 'application/json');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@ -531,41 +627,129 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
|
|
||||||
{/* Live feed */}
|
{/* Live feed */}
|
||||||
<div className="panel-section" style={{ gap: "6px" }}>
|
<div className="panel-section" style={{ gap: "6px" }}>
|
||||||
<div className="panel-section-title">Live Feed</div>
|
{/* Header row: title + view mode toggle */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
{completedSegments.map((seg, i) => (
|
<div className="panel-section-title" style={{ margin: 0 }}>Live Feed</div>
|
||||||
<div
|
<div style={{ display: "flex", gap: "3px" }}>
|
||||||
key={"completed-" + i}
|
{(["segments", "transcript"] as const).map(mode => (
|
||||||
style={{
|
<button
|
||||||
padding: "8px 10px",
|
key={mode}
|
||||||
backgroundColor: "var(--color-muted)",
|
onClick={() => setViewMode(mode)}
|
||||||
borderRadius: "4px",
|
style={{
|
||||||
border: "1px solid var(--color-divider)",
|
padding: "2px 8px",
|
||||||
fontSize: "13px",
|
fontSize: "11px",
|
||||||
color: "var(--color-text)",
|
backgroundColor: viewMode === mode ? "#2563eb" : "var(--color-muted)",
|
||||||
lineHeight: 1.4,
|
color: viewMode === mode ? "#fff" : "var(--color-text-2)",
|
||||||
}}
|
border: "1px solid var(--color-divider)",
|
||||||
>
|
borderRadius: "3px",
|
||||||
{seg.text}
|
cursor: "pointer",
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
{currentSegment && (
|
{(() => {
|
||||||
<div
|
const allFinal = completedSegments; // already merged from server on every message
|
||||||
style={{
|
|
||||||
padding: "8px 10px",
|
if (viewMode === "segments") {
|
||||||
backgroundColor: "var(--color-panel)",
|
return (
|
||||||
borderRadius: "4px",
|
<>
|
||||||
border: "1px dashed var(--color-divider)",
|
{allFinal.map((seg, i) => (
|
||||||
fontSize: "13px",
|
<div
|
||||||
color: "var(--color-text-2)",
|
key={"seg-" + i}
|
||||||
fontStyle: "italic",
|
style={{
|
||||||
lineHeight: 1.4,
|
padding: "7px 10px",
|
||||||
}}
|
backgroundColor: "var(--color-muted)",
|
||||||
>
|
borderRadius: "6px",
|
||||||
{currentSegment.text || "Listening..."}
|
border: "1px solid var(--color-divider)",
|
||||||
</div>
|
}}
|
||||||
)}
|
>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "var(--color-text-2)",
|
||||||
|
marginBottom: "3px",
|
||||||
|
letterSpacing: "0.03em",
|
||||||
|
}}>
|
||||||
|
{formatTime(Math.floor(seg.start))} → {formatTime(Math.floor(seg.end))}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "13px", color: "var(--color-text)", lineHeight: 1.5 }}>
|
||||||
|
{seg.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{currentSegment && (
|
||||||
|
<div style={{
|
||||||
|
padding: "7px 10px",
|
||||||
|
backgroundColor: "var(--color-panel)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px dashed var(--color-divider)",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "var(--color-text-3)",
|
||||||
|
marginBottom: "3px",
|
||||||
|
letterSpacing: "0.03em",
|
||||||
|
}}>
|
||||||
|
{formatTime(Math.floor(currentSegment.start))} → …
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "13px", color: "var(--color-text-2)", lineHeight: 1.5, fontStyle: "italic" }}>
|
||||||
|
{currentSegment.text || "Listening…"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcript view — single joined box + separate live segment
|
||||||
|
const joinedText = allFinal.map(s => s.text.trim()).filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(joinedText || !currentSegment) && (
|
||||||
|
<div style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
backgroundColor: "var(--color-muted)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid var(--color-divider)",
|
||||||
|
fontSize: "13px",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
lineHeight: 1.7,
|
||||||
|
minHeight: "48px",
|
||||||
|
}}>
|
||||||
|
{joinedText || <span style={{ color: "var(--color-text-2)" }}>No completed segments yet.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentSegment && (
|
||||||
|
<div style={{
|
||||||
|
padding: "7px 10px",
|
||||||
|
backgroundColor: "var(--color-panel)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px dashed var(--color-divider)",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "var(--color-text-3)",
|
||||||
|
marginBottom: "3px",
|
||||||
|
}}>
|
||||||
|
{formatTime(Math.floor(currentSegment.start))} → …
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "13px", color: "var(--color-text-2)", lineHeight: 1.5, fontStyle: "italic" }}>
|
||||||
|
{currentSegment.text || "Listening…"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{!isRecording && completedSegments.length === 0 && !currentSegment && (
|
{!isRecording && completedSegments.length === 0 && !currentSegment && (
|
||||||
<div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "16px", fontSize: "13px" }}>
|
<div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "16px", fontSize: "13px" }}>
|
||||||
@ -806,67 +990,86 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
|
|
||||||
{/* Summary Type Selection Modal */}
|
{/* Summary Type Selection Modal */}
|
||||||
{showSummaryModal && (
|
{showSummaryModal && (
|
||||||
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowSummaryModal(false); }}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
style={{
|
||||||
onClick={() => setShowSummaryModal(false)}
|
position: 'relative',
|
||||||
/>
|
width: '100%',
|
||||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full max-w-sm mx-auto">
|
maxWidth: '360px',
|
||||||
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
backgroundColor: 'var(--color-panel)',
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
border: '1px solid var(--color-divider)',
|
||||||
Generate Summary
|
borderRadius: '10px',
|
||||||
</h3>
|
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--color-divider)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>Generate Summary</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSummaryModal(false)}
|
onClick={() => setShowSummaryModal(false)}
|
||||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
style={{ padding: '2px 6px', border: 'none', backgroundColor: 'transparent', color: 'var(--color-text-2)', cursor: 'pointer', fontSize: '18px', lineHeight: 1 }}
|
||||||
>
|
>
|
||||||
<Close sx={{ fontSize: 20 }} />
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-5 sm:p-6 space-y-4">
|
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-2)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
Summary Type
|
Summary Type
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={summaryType}
|
value={summaryType}
|
||||||
onChange={(e) => setSummaryType(e.target.value)}
|
onChange={(e) => setSummaryType(e.target.value)}
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
style={{ width: '100%', padding: '7px 10px', border: '1px solid var(--color-divider)', borderRadius: '6px', backgroundColor: 'var(--color-muted)', color: 'var(--color-text)', fontSize: '13px', outline: 'none' }}
|
||||||
>
|
>
|
||||||
{SUMMARY_TYPES.map((t) => (
|
{SUMMARY_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
{t.label}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config status indicator */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: "8px",
|
padding: '8px 10px',
|
||||||
borderRadius: "6px",
|
borderRadius: '6px',
|
||||||
fontSize: "12px",
|
fontSize: '12px',
|
||||||
backgroundColor: llmConfig.apiKey ? "#f0fdf4" : "#fef2f2",
|
backgroundColor: llmConfig.model ? 'var(--color-muted)' : '#fef2f2',
|
||||||
color: llmConfig.apiKey ? "#16a34a" : "#dc2626",
|
color: llmConfig.model ? 'var(--color-text-2)' : '#dc2626',
|
||||||
border: `1px solid ${llmConfig.apiKey ? "#bbf7d0" : "#fecaca"}`,
|
border: '1px solid var(--color-divider)',
|
||||||
}}>
|
}}>
|
||||||
{llmConfig.apiKey ? (
|
{llmConfig.model
|
||||||
<>✓ Configured: {llmConfig.provider} ({llmConfig.model || 'default model'})</>
|
? <>✓ {llmConfig.provider} · {llmConfig.model}</>
|
||||||
) : (
|
: <>⚠ No model configured — open Settings first</>
|
||||||
<>⚠ No API key configured. Click the ⚙ icon to set up.</>
|
}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateSummary}
|
onClick={handleGenerateSummary}
|
||||||
disabled={isGeneratingSummary || !llmConfig.apiKey}
|
disabled={isGeneratingSummary || !llmConfig.model}
|
||||||
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
|
style={{
|
||||||
isGeneratingSummary || !llmConfig.apiKey
|
padding: '9px',
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
border: 'none',
|
||||||
: 'bg-purple-600 hover:bg-purple-700'
|
borderRadius: '6px',
|
||||||
}`}
|
backgroundColor: isGeneratingSummary || !llmConfig.model ? '#9ca3af' : '#7c3aed',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isGeneratingSummary || !llmConfig.model ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isGeneratingSummary || !llmConfig.model ? 0.7 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isGeneratingSummary ? 'Generating...' : 'Generate'}
|
{isGeneratingSummary ? 'Generating…' : 'Generate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,49 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Close from '@mui/icons-material/Close';
|
|
||||||
import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore';
|
import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore';
|
||||||
|
|
||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
{ value: 'anthropic', label: 'Anthropic' },
|
{ value: 'anthropic', label: 'Anthropic' },
|
||||||
{ value: 'ollama', label: 'Ollama' },
|
{ value: 'ollama', label: 'Ollama (local)' },
|
||||||
{ value: 'openrouter', label: 'OpenRouter' },
|
{ value: 'openrouter', label: 'OpenRouter' },
|
||||||
{ value: 'google', label: 'Google' },
|
{ value: 'google', label: 'Google Gemini' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const WHISPER_MODELS = [
|
||||||
|
{ value: 'tiny', label: 'Tiny (fastest, least accurate)' },
|
||||||
|
{ value: 'tiny.en', label: 'Tiny English' },
|
||||||
|
{ value: 'base', label: 'Base' },
|
||||||
|
{ value: 'base.en', label: 'Base English' },
|
||||||
|
{ value: 'small', label: 'Small' },
|
||||||
|
{ value: 'small.en', label: 'Small English' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'medium.en', label: 'Medium English' },
|
||||||
|
{ value: 'large-v2', label: 'Large v2' },
|
||||||
|
{ value: 'large-v3', label: 'Large v3 (best accuracy)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fieldStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '7px 10px',
|
||||||
|
border: '1px solid var(--color-divider)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: 'var(--color-muted)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontSize: '13px',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
marginBottom: '4px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
};
|
||||||
|
|
||||||
const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||||
const { llmConfig, setLLMConfig } = useTranscriptionStore();
|
const { llmConfig, setLLMConfig } = useTranscriptionStore();
|
||||||
const [form, setForm] = useState<LLMConfig>(llmConfig);
|
const [form, setForm] = useState<LLMConfig>(llmConfig);
|
||||||
@ -25,97 +59,196 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is
|
|||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setLLMConfig(form);
|
setLLMConfig(form);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => {
|
||||||
|
setSaved(false);
|
||||||
|
onClose();
|
||||||
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 99999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
|
||||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
{/* Modal panel */}
|
||||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full max-w-md mx-auto">
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '420px',
|
||||||
|
backgroundColor: 'var(--color-panel)',
|
||||||
|
border: '1px solid var(--color-divider)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
<div style={{
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
padding: '14px 16px',
|
||||||
LLM Provider Settings
|
borderBottom: '1px solid var(--color-divider)',
|
||||||
</h3>
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Close sx={{ fontSize: 20 }} />
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 py-5 sm:p-6 space-y-4">
|
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '80vh', overflowY: 'auto' }}>
|
||||||
{/* Provider dropdown */}
|
|
||||||
|
{/* ── Transcription section ── */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div style={{
|
||||||
Provider
|
fontSize: '11px',
|
||||||
</label>
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
marginBottom: '10px',
|
||||||
|
paddingBottom: '6px',
|
||||||
|
borderBottom: '1px solid var(--color-divider)',
|
||||||
|
}}>
|
||||||
|
Transcription
|
||||||
|
</div>
|
||||||
|
<label style={labelStyle}>Whisper Model</label>
|
||||||
<select
|
<select
|
||||||
value={form.provider}
|
value={form.whisperModel || 'large-v3'}
|
||||||
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
|
onChange={(e) => setForm({ ...form, whisperModel: e.target.value })}
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
style={fieldStyle}
|
||||||
>
|
>
|
||||||
{PROVIDERS.map((p) => (
|
{WHISPER_MODELS.map((m) => (
|
||||||
<option key={p.value} value={p.value}>
|
<option key={m.value} value={m.value}>{m.label}</option>
|
||||||
{p.label}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '4px' }}>
|
||||||
|
Larger models are more accurate but slower to load. Server has large-v3 downloaded.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model name */}
|
{/* ── LLM section ── */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div style={{
|
||||||
Model
|
fontSize: '11px',
|
||||||
</label>
|
fontWeight: 700,
|
||||||
<input
|
color: 'var(--color-text-3)',
|
||||||
type="text"
|
textTransform: 'uppercase',
|
||||||
value={form.model}
|
letterSpacing: '0.08em',
|
||||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
marginBottom: '10px',
|
||||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
paddingBottom: '6px',
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
borderBottom: '1px solid var(--color-divider)',
|
||||||
/>
|
}}>
|
||||||
</div>
|
AI Summary Provider
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label style={labelStyle}>Provider</label>
|
||||||
API Key
|
<select
|
||||||
</label>
|
value={form.provider}
|
||||||
<input
|
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
|
||||||
type="password"
|
style={fieldStyle}
|
||||||
value={form.apiKey}
|
>
|
||||||
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
{PROVIDERS.map((p) => (
|
||||||
placeholder="sk-..."
|
<option key={p.value} value={p.value}>{p.label}</option>
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
))}
|
||||||
/>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note */}
|
<div>
|
||||||
<p className="text-xs text-gray-500">
|
<label style={labelStyle}>Model</label>
|
||||||
API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server.
|
<input
|
||||||
</p>
|
type="text"
|
||||||
|
value={form.model}
|
||||||
|
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
form.provider === 'ollama' ? 'e.g. gemma4:e4b, llama3.2' :
|
||||||
|
form.provider === 'anthropic' ? 'e.g. claude-sonnet-4-6' :
|
||||||
|
form.provider === 'google' ? 'e.g. gemini-2.0-flash' :
|
||||||
|
'e.g. gpt-4o, gpt-4o-mini'
|
||||||
|
}
|
||||||
|
style={fieldStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.provider === 'ollama' && (
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Ollama Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.baseUrl || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||||
|
placeholder="https://ollama.kevlarai.com"
|
||||||
|
style={fieldStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{form.provider === 'ollama' ? 'API Key (optional — leave blank if unrestricted)' : 'API Key'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
||||||
|
placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : 'sk-...'}
|
||||||
|
style={fieldStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '8px' }}>
|
||||||
|
API keys are stored in your browser only.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Save button */}
|
{/* Save button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
|
style={{
|
||||||
saved
|
padding: '9px',
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
border: 'none',
|
||||||
: 'bg-blue-600 hover:bg-blue-700'
|
borderRadius: '6px',
|
||||||
}`}
|
backgroundColor: saved ? '#16a34a' : '#2563eb',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 200ms',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{saved ? '✓ Saved!' : 'Save Settings'}
|
{saved ? '✓ Saved' : 'Save Settings'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,586 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
||||||
|
import {
|
||||||
|
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
|
||||||
|
ToggleButtonGroup, ToggleButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ExpandMore, ChevronRight as ChevronRightIcon,
|
||||||
|
Home as HomeIcon,
|
||||||
|
CalendarToday, DateRange, Event,
|
||||||
|
Schedule as TimetableIcon,
|
||||||
|
Class as ClassIcon,
|
||||||
|
MenuBook as CurriculumIcon,
|
||||||
|
Book as JournalIcon,
|
||||||
|
EventNote as PlannerIcon,
|
||||||
|
Business as SchoolIcon,
|
||||||
|
LinkOff as UnlinkedIcon,
|
||||||
|
HourglassEmpty as PendingIcon,
|
||||||
|
School as AcademicIcon,
|
||||||
|
GridOn as GridIcon,
|
||||||
|
Settings as SetupIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||||
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
|
import { NeoGraphNode } from '../../../../../../types/navigation';
|
||||||
|
import { logger } from '../../../../../../debugConfig';
|
||||||
|
import { SchoolCalendarWizard, SchoolInfo } from './SchoolCalendarWizard';
|
||||||
|
import { TeacherTimetableWizard, PeriodTemplate } from './TeacherTimetableWizard';
|
||||||
|
|
||||||
|
type NodeStatus = 'populated' | 'empty' | 'no_school' | 'not_initialized';
|
||||||
|
type CalendarMode = 'generic' | 'academic';
|
||||||
|
|
||||||
|
interface TreeNode extends NeoGraphNode {
|
||||||
|
has_children?: boolean;
|
||||||
|
children?: TreeNode[];
|
||||||
|
is_section?: boolean;
|
||||||
|
section_id?: string;
|
||||||
|
status?: NodeStatus;
|
||||||
|
neo4j_props?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchoolStatus {
|
||||||
|
status: string;
|
||||||
|
user_role?: string;
|
||||||
|
school_id?: string;
|
||||||
|
school_has_calendar?: boolean;
|
||||||
|
teacher_has_timetable?: boolean;
|
||||||
|
timetable_id?: string | null;
|
||||||
|
periods_template?: PeriodTemplate[] | null;
|
||||||
|
school_info?: SchoolInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_ICONS: Record<string, React.ElementType> = {
|
||||||
|
User: HomeIcon,
|
||||||
|
CalendarYear: CalendarToday,
|
||||||
|
CalendarMonth: DateRange,
|
||||||
|
CalendarWeek: DateRange,
|
||||||
|
CalendarDay: Event,
|
||||||
|
AcademicYear: AcademicIcon,
|
||||||
|
AcademicTerm: AcademicIcon,
|
||||||
|
AcademicWeek: DateRange,
|
||||||
|
TeacherTimetable: TimetableIcon,
|
||||||
|
SubjectClass: ClassIcon,
|
||||||
|
TimetableLesson: TimetableIcon,
|
||||||
|
TimetableSlot: GridIcon,
|
||||||
|
Journal: JournalIcon,
|
||||||
|
Planner: PlannerIcon,
|
||||||
|
School: SchoolIcon,
|
||||||
|
Department: SchoolIcon,
|
||||||
|
Section: HomeIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_ICONS: Record<string, React.ElementType> = {
|
||||||
|
calendar: CalendarToday,
|
||||||
|
timetable: TimetableIcon,
|
||||||
|
classes: ClassIcon,
|
||||||
|
curriculum: CurriculumIcon,
|
||||||
|
journal: JournalIcon,
|
||||||
|
planner: PlannerIcon,
|
||||||
|
school: SchoolIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_MESSAGES: Record<NodeStatus, string> = {
|
||||||
|
populated: '',
|
||||||
|
empty: 'Not set up yet',
|
||||||
|
no_school: 'Join a school to unlock',
|
||||||
|
not_initialized: 'Setting up...',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Panel context ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NavPanelContextValue {
|
||||||
|
calendarMode: CalendarMode;
|
||||||
|
setCalendarMode: (m: CalendarMode) => void;
|
||||||
|
academicCalendarStatus: 'idle' | 'loading' | 'populated' | 'empty' | 'no_school' | 'error';
|
||||||
|
academicTerms: TreeNode[];
|
||||||
|
schoolStatus: SchoolStatus | null;
|
||||||
|
onSetupSchoolCalendar: () => void;
|
||||||
|
onSetupTimetable: () => void;
|
||||||
|
activeNodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavPanelContext = createContext<NavPanelContextValue>({
|
||||||
|
calendarMode: 'generic',
|
||||||
|
setCalendarMode: () => {},
|
||||||
|
academicCalendarStatus: 'idle',
|
||||||
|
academicTerms: [],
|
||||||
|
schoolStatus: null,
|
||||||
|
onSetupSchoolCalendar: () => {},
|
||||||
|
onSetupTimetable: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── TreeItem ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TreeItemProps {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
onSelect: (node: TreeNode) => void;
|
||||||
|
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
|
||||||
|
const ctx = useContext(NavPanelContext);
|
||||||
|
const [expanded, setExpanded] = useState(node.is_section && node.status === 'populated');
|
||||||
|
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isSection = !!node.is_section;
|
||||||
|
const isCalendarSection = isSection && node.section_id === 'calendar';
|
||||||
|
const isTimetableSection = isSection && node.section_id === 'timetable';
|
||||||
|
const isSchoolSection = isSection && node.section_id === 'school';
|
||||||
|
const SectionIcon = node.section_id ? SECTION_ICONS[node.section_id] : null;
|
||||||
|
const Icon = SectionIcon || NODE_ICONS[node.node_type] || HomeIcon;
|
||||||
|
|
||||||
|
const canExpand = node.has_children !== false
|
||||||
|
&& node.node_type !== 'CalendarDay'
|
||||||
|
&& node.node_type !== 'AcademicWeek'
|
||||||
|
&& node.status !== 'empty'
|
||||||
|
&& node.status !== 'no_school'
|
||||||
|
&& node.status !== 'not_initialized';
|
||||||
|
|
||||||
|
const isActive = !isSection && node.neo4j_node_id === ctx.activeNodeId;
|
||||||
|
const isEmpty = node.status === 'empty' || node.status === 'no_school' || node.status === 'not_initialized';
|
||||||
|
|
||||||
|
const displayChildren = isCalendarSection && ctx.calendarMode === 'academic'
|
||||||
|
? ctx.academicTerms
|
||||||
|
: children;
|
||||||
|
|
||||||
|
const academicEmpty = isCalendarSection
|
||||||
|
&& ctx.calendarMode === 'academic'
|
||||||
|
&& (ctx.academicCalendarStatus === 'empty' || ctx.academicCalendarStatus === 'idle');
|
||||||
|
|
||||||
|
const handleToggle = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!expanded && displayChildren.length === 0 && canExpand && !isCalendarSection) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const loaded = await onExpand(node);
|
||||||
|
setChildren(loaded);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpanded(v => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!isSection) {
|
||||||
|
onSelect(node);
|
||||||
|
} else if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) {
|
||||||
|
handleToggle({ stopPropagation: () => {} } as React.MouseEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive action buttons per section
|
||||||
|
const ss = ctx.schoolStatus;
|
||||||
|
// School section: calendar setup (admin) or pending notice (non-admin)
|
||||||
|
const showCalendarSetup = isSchoolSection
|
||||||
|
&& ss && ss.status !== 'no_school'
|
||||||
|
&& !ss.school_has_calendar && ss.user_role === 'school_admin';
|
||||||
|
const showCalendarPending = isSchoolSection
|
||||||
|
&& ss && ss.status !== 'no_school'
|
||||||
|
&& !ss.school_has_calendar && ss.user_role !== 'school_admin';
|
||||||
|
// Timetable section: teacher timetable setup (requires school calendar first)
|
||||||
|
const showTimetableSetup = isTimetableSection && node.status === 'empty'
|
||||||
|
&& ss && ss.status !== 'no_school'
|
||||||
|
&& ss.school_has_calendar && !ss.teacher_has_timetable;
|
||||||
|
const showLegacySetup = isTimetableSection && node.status === 'empty' && !ss;
|
||||||
|
const showTimetableEdit = isTimetableSection && node.status === 'populated'
|
||||||
|
&& ss && ss.status !== 'no_school' && !!ss.teacher_has_timetable;
|
||||||
|
|
||||||
|
if (isSection) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
onClick={handleClick}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
px: 1, py: 0.6,
|
||||||
|
cursor: (canExpand || isCalendarSection) ? 'pointer' : 'default',
|
||||||
|
mt: depth === 0 ? 0.5 : 0,
|
||||||
|
borderRadius: 1,
|
||||||
|
'&:hover': (canExpand || isCalendarSection) ? { bgcolor: 'action.hover' } : {},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||||
|
{(canExpand || (isCalendarSection && !academicEmpty)) && (
|
||||||
|
loading
|
||||||
|
? <CircularProgress size={10} />
|
||||||
|
: (
|
||||||
|
<IconButton size="small" sx={{ p: 0, color: 'text.secondary' }} onClick={handleToggle}>
|
||||||
|
{expanded
|
||||||
|
? <ExpandMore sx={{ fontSize: 14 }} />
|
||||||
|
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && (
|
||||||
|
<Box sx={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{node.status === 'no_school'
|
||||||
|
? <UnlinkedIcon sx={{ fontSize: 11, color: 'text.disabled' }} />
|
||||||
|
: node.status === 'not_initialized'
|
||||||
|
? <PendingIcon sx={{ fontSize: 11, color: 'text.disabled' }} />
|
||||||
|
: null}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Icon sx={{ fontSize: 13, mr: 0.75, flexShrink: 0, color: isEmpty ? 'text.disabled' : 'primary.main', opacity: isEmpty ? 0.5 : 1 }} />
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600, letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase', fontSize: '0.68rem',
|
||||||
|
color: isEmpty ? 'text.disabled' : 'text.secondary',
|
||||||
|
flexGrow: 1, overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.label}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && node.status && (
|
||||||
|
<Tooltip title={STATUS_MESSAGES[node.status]} placement="right">
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.6rem', color: 'text.disabled', ml: 0.5, flexShrink: 0 }}>
|
||||||
|
{node.status === 'no_school' ? '—' : '…'}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timetable section — role-aware action */}
|
||||||
|
{showCalendarSetup && (
|
||||||
|
<Tooltip title="Set up school calendar" placement="right">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
|
||||||
|
onClick={e => { e.stopPropagation(); ctx.onSetupSchoolCalendar(); }}
|
||||||
|
>
|
||||||
|
<SetupIcon sx={{ fontSize: 13 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{showCalendarPending && (
|
||||||
|
<Tooltip title="School calendar not set up yet — contact your school admin" placement="right">
|
||||||
|
<PendingIcon sx={{ fontSize: 11, color: 'text.disabled', ml: 0.5 }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{showTimetableSetup && (
|
||||||
|
<Tooltip title="Set up my timetable" placement="right">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
|
||||||
|
onClick={e => { e.stopPropagation(); ctx.onSetupTimetable(); }}
|
||||||
|
>
|
||||||
|
<SetupIcon sx={{ fontSize: 13 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{showLegacySetup && (
|
||||||
|
<Tooltip title="Set up timetable" placement="right">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
|
||||||
|
onClick={e => { e.stopPropagation(); ctx.onSetupTimetable(); }}
|
||||||
|
>
|
||||||
|
<SetupIcon sx={{ fontSize: 13 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{showTimetableEdit && (
|
||||||
|
<Tooltip title="Edit my class schedule" placement="right">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary' }}
|
||||||
|
onClick={e => { e.stopPropagation(); ctx.onSetupTimetable(); }}
|
||||||
|
>
|
||||||
|
<EditIcon sx={{ fontSize: 13 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Calendar mode toggle */}
|
||||||
|
{isCalendarSection && (
|
||||||
|
<Box sx={{ px: 1.5, pb: 0.5 }}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={ctx.calendarMode}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, v) => { if (v) ctx.setCalendarMode(v); }}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 22 }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="generic" sx={{ px: 1, fontSize: '0.6rem', textTransform: 'none' }}>
|
||||||
|
Generic
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="academic" sx={{ px: 1, fontSize: '0.6rem', textTransform: 'none' }}>
|
||||||
|
Academic
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
{ctx.calendarMode === 'academic' && academicEmpty && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.disabled', fontSize: '0.6rem', mt: 0.5 }}>
|
||||||
|
No academic calendar — set up school calendar first
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{ctx.calendarMode === 'academic' && ctx.academicCalendarStatus === 'loading' && (
|
||||||
|
<CircularProgress size={10} sx={{ mt: 0.5, ml: 0.5 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(canExpand || (isCalendarSection && ctx.calendarMode === 'academic' && displayChildren.length > 0)) && (
|
||||||
|
<Collapse in={expanded} timeout="auto">
|
||||||
|
{displayChildren.map(child => (
|
||||||
|
<TreeItem key={child.neo4j_node_id} node={child} depth={depth + 1}
|
||||||
|
onSelect={onSelect} onExpand={onExpand} />
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular navigable node
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
onClick={handleClick}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.35,
|
||||||
|
cursor: 'pointer', borderRadius: 1, mx: 0.5,
|
||||||
|
fontSize: '0.78rem', minHeight: 26,
|
||||||
|
bgcolor: isActive ? 'action.selected' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: isActive ? 'action.selected' : 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||||
|
{canExpand && (
|
||||||
|
loading
|
||||||
|
? <CircularProgress size={10} />
|
||||||
|
: (
|
||||||
|
<IconButton size="small" sx={{ p: 0, color: 'text.secondary' }} onClick={handleToggle}>
|
||||||
|
{expanded
|
||||||
|
? <ExpandMore sx={{ fontSize: 14 }} />
|
||||||
|
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Icon sx={{ fontSize: 13, mr: 0.75, flexShrink: 0, color: isActive ? 'primary.main' : 'text.secondary' }} />
|
||||||
|
<Box sx={{
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
flexGrow: 1, fontSize: '0.78rem',
|
||||||
|
color: isActive ? 'primary.main' : 'text.primary',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}>
|
||||||
|
{node.label}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{canExpand && (
|
||||||
|
<Collapse in={expanded} timeout="auto">
|
||||||
|
{children.map(child => (
|
||||||
|
<TreeItem key={child.neo4j_node_id} node={child} depth={depth + 1}
|
||||||
|
onSelect={onSelect} onExpand={onExpand} />
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CCGraphNavPanel() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const { navigateToNeoNode, context } = useNavigationStore();
|
||||||
|
const [tree, setTree] = useState<TreeNode | null>(null);
|
||||||
|
const [schoolStatus, setSchoolStatus] = useState<SchoolStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [calendarMode, setCalendarMode] = useState<CalendarMode>('generic');
|
||||||
|
const [academicCalendarStatus, setAcademicCalendarStatus] = useState<NavPanelContextValue['academicCalendarStatus']>('idle');
|
||||||
|
const [academicTerms, setAcademicTerms] = useState<TreeNode[]>([]);
|
||||||
|
|
||||||
|
const [calendarWizardOpen, setCalendarWizardOpen] = useState(false);
|
||||||
|
const [timetableWizardOpen, setTimetableWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||||
|
const activeNodeId = context.node?.type !== 'workspace' ? context.node?.id : undefined;
|
||||||
|
|
||||||
|
const fetchTree = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/graph/tree`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setTree(data.tree);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('graph-nav-panel', 'Failed to load graph tree', err);
|
||||||
|
setError('Failed to load navigation tree');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
|
const fetchSchoolStatus = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/school/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setSchoolStatus(data);
|
||||||
|
} catch {
|
||||||
|
// non-fatal — panel still works without school status
|
||||||
|
}
|
||||||
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessToken && !tree) fetchTree();
|
||||||
|
}, [accessToken, tree, fetchTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessToken && !schoolStatus) fetchSchoolStatus();
|
||||||
|
}, [accessToken, schoolStatus, fetchSchoolStatus]);
|
||||||
|
|
||||||
|
// Fetch academic calendar when switching to academic mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (calendarMode !== 'academic' || !accessToken) return;
|
||||||
|
if (academicCalendarStatus !== 'idle') return;
|
||||||
|
setAcademicCalendarStatus('loading');
|
||||||
|
fetch(`${apiBase}/graph/calendar/academic`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'populated') {
|
||||||
|
setAcademicTerms(data.terms);
|
||||||
|
setAcademicCalendarStatus('populated');
|
||||||
|
} else {
|
||||||
|
setAcademicCalendarStatus(data.status || 'empty');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setAcademicCalendarStatus('error'));
|
||||||
|
}, [calendarMode, accessToken, apiBase, academicCalendarStatus]);
|
||||||
|
|
||||||
|
const handleSetCalendarMode = useCallback((m: CalendarMode) => {
|
||||||
|
setCalendarMode(m);
|
||||||
|
if (m === 'academic') setAcademicCalendarStatus('idle');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
|
||||||
|
if (!accessToken) return [];
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
neo4j_node_id: node.neo4j_node_id,
|
||||||
|
neo4j_db_name: node.neo4j_db_name,
|
||||||
|
node_type: node.node_type,
|
||||||
|
section_id: node.section_id || '',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.children || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((node: TreeNode) => {
|
||||||
|
if (!node.is_section) navigateToNeoNode(node);
|
||||||
|
}, [navigateToNeoNode]);
|
||||||
|
|
||||||
|
const refreshAll = useCallback(() => {
|
||||||
|
setTree(null);
|
||||||
|
setSchoolStatus(null);
|
||||||
|
setAcademicCalendarStatus('idle');
|
||||||
|
setAcademicTerms([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCalendarWizardComplete = useCallback(() => {
|
||||||
|
logger.info('graph-nav-panel', 'School calendar setup complete');
|
||||||
|
refreshAll();
|
||||||
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
|
||||||
|
logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId });
|
||||||
|
refreshAll();
|
||||||
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5, fontSize: '0.78rem', color: 'error.main' }}>
|
||||||
|
{error}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tree) return null;
|
||||||
|
|
||||||
|
const ctxValue: NavPanelContextValue = {
|
||||||
|
calendarMode,
|
||||||
|
setCalendarMode: handleSetCalendarMode,
|
||||||
|
academicCalendarStatus,
|
||||||
|
academicTerms,
|
||||||
|
schoolStatus,
|
||||||
|
onSetupSchoolCalendar: () => setCalendarWizardOpen(true),
|
||||||
|
onSetupTimetable: () => setTimetableWizardOpen(true),
|
||||||
|
activeNodeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSchoolInfo: SchoolInfo = {
|
||||||
|
name: '', urn: '', website: '', address: {},
|
||||||
|
headteacher: '', term_dates_url: '', staff_list_url: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavPanelContext.Provider value={ctxValue}>
|
||||||
|
<Box sx={{ pt: 0.5, pb: 2 }}>
|
||||||
|
<TreeItem
|
||||||
|
node={tree}
|
||||||
|
depth={0}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onExpand={handleExpand}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{schoolStatus?.school_info && (
|
||||||
|
<SchoolCalendarWizard
|
||||||
|
open={calendarWizardOpen}
|
||||||
|
onClose={() => setCalendarWizardOpen(false)}
|
||||||
|
onComplete={handleCalendarWizardComplete}
|
||||||
|
apiBase={apiBase}
|
||||||
|
schoolInfo={schoolStatus.school_info || defaultSchoolInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TeacherTimetableWizard
|
||||||
|
open={timetableWizardOpen}
|
||||||
|
onClose={() => setTimetableWizardOpen(false)}
|
||||||
|
onComplete={handleTimetableWizardComplete}
|
||||||
|
apiBase={apiBase}
|
||||||
|
periodsTemplate={schoolStatus?.periods_template || []}
|
||||||
|
timetableId={schoolStatus?.timetable_id || null}
|
||||||
|
/>
|
||||||
|
</NavPanelContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import Save from '@mui/icons-material/Save';
|
|||||||
import Reset from '@mui/icons-material/RestartAlt';
|
import Reset from '@mui/icons-material/RestartAlt';
|
||||||
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
||||||
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||||
import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService';
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
import { PageComponent } from '../components/pageComponent';
|
import { PageComponent } from '../components/pageComponent';
|
||||||
import { logger } from '../../../../../../debugConfig';
|
import { logger } from '../../../../../../debugConfig';
|
||||||
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
|
||||||
@ -68,6 +68,7 @@ export const CCNodeSnapshotPanel: React.FC = () => {
|
|||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const { context: navigationContext, isLoading, error } = useNavigationStore();
|
const { context: navigationContext, isLoading, error } = useNavigationStore();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const { tldrawPreferences } = useTLDraw();
|
const { tldrawPreferences } = useTLDraw();
|
||||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@ -131,8 +132,9 @@ export const CCNodeSnapshotPanel: React.FC = () => {
|
|||||||
type: navigationContext.node.type
|
type: navigationContext.node.type
|
||||||
});
|
});
|
||||||
|
|
||||||
const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node);
|
const storagePath = navigationContext.node.node_storage_path;
|
||||||
await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store);
|
if (!storagePath) throw new Error('No storage path on current node');
|
||||||
|
await NavigationSnapshotService.saveNodeSnapshotToDatabase(storagePath, accessToken || '', editor.store);
|
||||||
|
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Snapshot saved',
|
title: 'Snapshot saved',
|
||||||
|
|||||||
@ -0,0 +1,316 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
Button, Stepper, Step, StepLabel, Box, TextField,
|
||||||
|
Typography, IconButton, Select, MenuItem, FormControl,
|
||||||
|
InputLabel, CircularProgress, Alert, Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface TermInput {
|
||||||
|
name: string;
|
||||||
|
term_number: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
period_type: 'lesson' | 'break' | 'registration';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchoolInfo {
|
||||||
|
name: string;
|
||||||
|
urn: string;
|
||||||
|
website: string;
|
||||||
|
address: Record<string, string>;
|
||||||
|
headteacher: string;
|
||||||
|
term_dates_url: string;
|
||||||
|
staff_list_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TERMS: TermInput[] = [
|
||||||
|
{ name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' },
|
||||||
|
{ name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01' },
|
||||||
|
{ name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_PERIODS: PeriodInput[] = [
|
||||||
|
{ code: 'REG', name: 'Registration', start_time: '08:45', end_time: '09:00', period_type: 'registration' },
|
||||||
|
{ code: 'P1', name: 'Period 1', start_time: '09:00', end_time: '10:00', period_type: 'lesson' },
|
||||||
|
{ code: 'P2', name: 'Period 2', start_time: '10:00', end_time: '11:00', period_type: 'lesson' },
|
||||||
|
{ code: 'BREAK', name: 'Break', start_time: '11:00', end_time: '11:20', period_type: 'break' },
|
||||||
|
{ code: 'P3', name: 'Period 3', start_time: '11:20', end_time: '12:20', period_type: 'lesson' },
|
||||||
|
{ code: 'P4', name: 'Period 4', start_time: '12:20', end_time: '13:20', period_type: 'lesson' },
|
||||||
|
{ code: 'LUNCH', name: 'Lunch', start_time: '13:20', end_time: '14:05', period_type: 'break' },
|
||||||
|
{ code: 'P5', name: 'Period 5', start_time: '14:05', end_time: '15:05', period_type: 'lesson' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
apiBase: string;
|
||||||
|
schoolInfo: SchoolInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoolInfo }: Props) {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || '');
|
||||||
|
const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || '');
|
||||||
|
const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || '');
|
||||||
|
|
||||||
|
const [yearStart, setYearStart] = useState('2025-09-01');
|
||||||
|
const [yearEnd, setYearEnd] = useState('2026-07-31');
|
||||||
|
const [terms, setTerms] = useState<TermInput[]>(DEFAULT_TERMS);
|
||||||
|
|
||||||
|
const [periods, setPeriods] = useState<PeriodInput[]>(DEFAULT_PERIODS);
|
||||||
|
|
||||||
|
const addTerm = () => setTerms(prev => [...prev, {
|
||||||
|
name: `Term ${prev.length + 1}`,
|
||||||
|
term_number: prev.length + 1,
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const removeTerm = (i: number) => setTerms(prev =>
|
||||||
|
prev.filter((_, idx) => idx !== i).map((t, idx) => ({ ...t, term_number: idx + 1 }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTerm = (i: number, field: keyof TermInput, value: string) =>
|
||||||
|
setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t));
|
||||||
|
|
||||||
|
const addPeriod = () => setPeriods(prev => [...prev, {
|
||||||
|
code: `P${prev.length + 1}`,
|
||||||
|
name: `Period ${prev.length + 1}`,
|
||||||
|
start_time: '',
|
||||||
|
end_time: '',
|
||||||
|
period_type: 'lesson',
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const removePeriod = (i: number) => setPeriods(prev => prev.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
const updatePeriod = (i: number, field: keyof PeriodInput, value: string) =>
|
||||||
|
setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p));
|
||||||
|
|
||||||
|
const handleSaveSchoolInfo = async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/school/info`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ headteacher, term_dates_url: termDatesUrl, staff_list_url: staffListUrl }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
setStep(1);
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Failed to save school info');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCalendar = async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/timetable/setup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ year_start: yearStart, year_end: yearEnd, terms, periods }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
onComplete();
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Calendar setup failed');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setStep(0);
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addr = schoolInfo.address || {};
|
||||||
|
const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
const STEPS = ['School Details', 'Academic Calendar', 'Daily Periods'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>Set Up School Calendar</DialogTitle>
|
||||||
|
<Box sx={{ px: 3 }}>
|
||||||
|
<Stepper activeStep={step} sx={{ mb: 2 }}>
|
||||||
|
{STEPS.map(label => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
|
||||||
|
</Stepper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{step === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Information</Typography>
|
||||||
|
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{schoolInfo.name || '—'}</Typography>
|
||||||
|
{schoolInfo.urn && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>URN: {schoolInfo.urn}</Typography>
|
||||||
|
)}
|
||||||
|
{addressStr && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{addressStr}</Typography>
|
||||||
|
)}
|
||||||
|
{schoolInfo.website && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{schoolInfo.website}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Additional Details</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Headteacher"
|
||||||
|
value={headteacher}
|
||||||
|
onChange={e => setHeadteacher(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="e.g. Mr J Smith"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Term Dates URL"
|
||||||
|
value={termDatesUrl}
|
||||||
|
onChange={e => setTermDatesUrl(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Link to term dates page on school website"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Staff List URL"
|
||||||
|
value={staffListUrl}
|
||||||
|
onChange={e => setStaffListUrl(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Link to staff list page on school website"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Year</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||||
|
<TextField label="Year Start" type="date" value={yearStart}
|
||||||
|
onChange={e => setYearStart(e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<TextField label="Year End" type="date" value={yearEnd}
|
||||||
|
onChange={e => setYearEnd(e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle2">Terms</Typography>
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addTerm}>Add Term</Button>
|
||||||
|
</Box>
|
||||||
|
{terms.map((term, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||||
|
<TextField label="Term Name" value={term.name}
|
||||||
|
onChange={e => updateTerm(i, 'name', e.target.value)}
|
||||||
|
size="small" sx={{ width: 140 }} />
|
||||||
|
<TextField label="Start Date" type="date" value={term.start_date}
|
||||||
|
onChange={e => updateTerm(i, 'start_date', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<TextField label="End Date" type="date" value={term.end_date}
|
||||||
|
onChange={e => updateTerm(i, 'end_date', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<IconButton size="small" onClick={() => removeTerm(i)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle2">Daily Period Schedule</Typography>
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addPeriod}>Add Period</Button>
|
||||||
|
</Box>
|
||||||
|
{periods.map((p, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||||
|
<TextField label="Code" value={p.code}
|
||||||
|
onChange={e => updatePeriod(i, 'code', e.target.value)}
|
||||||
|
size="small" sx={{ width: 80 }} />
|
||||||
|
<TextField label="Name" value={p.name}
|
||||||
|
onChange={e => updatePeriod(i, 'name', e.target.value)}
|
||||||
|
size="small" sx={{ width: 140 }} />
|
||||||
|
<TextField label="Start" type="time" value={p.start_time}
|
||||||
|
onChange={e => updatePeriod(i, 'start_time', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<TextField label="End" type="time" value={p.end_time}
|
||||||
|
onChange={e => updatePeriod(i, 'end_time', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<FormControl size="small" sx={{ width: 130 }}>
|
||||||
|
<InputLabel>Type</InputLabel>
|
||||||
|
<Select label="Type" value={p.period_type}
|
||||||
|
onChange={e => updatePeriod(i, 'period_type', e.target.value)}>
|
||||||
|
<MenuItem value="lesson">Lesson</MenuItem>
|
||||||
|
<MenuItem value="break">Break</MenuItem>
|
||||||
|
<MenuItem value="registration">Registration</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<IconButton size="small" onClick={() => removePeriod(i)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={handleClose} disabled={saving}>Cancel</Button>
|
||||||
|
{step > 0 && (
|
||||||
|
<Button onClick={() => setStep(s => s - 1)} disabled={saving}>Back</Button>
|
||||||
|
)}
|
||||||
|
{step === 0 && (
|
||||||
|
<Button onClick={handleSaveSchoolInfo} variant="contained" disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={18} /> : 'Save & Continue'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 1 && (
|
||||||
|
<Button onClick={() => setStep(2)} variant="outlined" disabled={saving}>
|
||||||
|
Next: Daily Periods
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<Button onClick={handleSaveCalendar} variant="contained" disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={18} /> : 'Save School Calendar'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,244 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
Button, Box, TextField, Typography, Table, TableHead,
|
||||||
|
TableBody, TableRow, TableCell, CircularProgress, Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
|
|
||||||
|
export interface PeriodTemplate {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
period_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
||||||
|
|
||||||
|
function emptyGrid(): Record<string, Record<string, string>> {
|
||||||
|
const g: Record<string, Record<string, string>> = {};
|
||||||
|
DAYS.forEach(d => { g[d] = {}; });
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onComplete: (timetableId: string) => void;
|
||||||
|
apiBase: string;
|
||||||
|
periodsTemplate: PeriodTemplate[];
|
||||||
|
timetableId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeacherTimetableWizard({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onComplete,
|
||||||
|
apiBase,
|
||||||
|
periodsTemplate,
|
||||||
|
timetableId: initialTimetableId,
|
||||||
|
}: Props) {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [localTimetableId, setLocalTimetableId] = useState<string | null>(initialTimetableId);
|
||||||
|
const [initializing, setInitializing] = useState(false);
|
||||||
|
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [grid, setGrid] = useState<Record<string, Record<string, string>>>(emptyGrid);
|
||||||
|
const slotsLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson');
|
||||||
|
const isEditing = !!initialTimetableId;
|
||||||
|
|
||||||
|
// Reset when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
slotsLoadedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalTimetableId(initialTimetableId);
|
||||||
|
setGrid(emptyGrid());
|
||||||
|
setError(null);
|
||||||
|
slotsLoadedRef.current = false;
|
||||||
|
}, [open, initialTimetableId]);
|
||||||
|
|
||||||
|
// Auto-create TeacherTimetable node if not yet done
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || localTimetableId || !accessToken || initializing) return;
|
||||||
|
setInitializing(true);
|
||||||
|
setError(null);
|
||||||
|
fetch(`${apiBase}/timetable/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
setLocalTimetableId(data.timetable_id);
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Could not initialize timetable. Has an admin set up the school calendar?');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setInitializing(false));
|
||||||
|
}, [open, localTimetableId, accessToken, apiBase, initializing]);
|
||||||
|
|
||||||
|
// Load existing slots when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !localTimetableId || !accessToken || slotsLoadedRef.current || loadingSlots) return;
|
||||||
|
slotsLoadedRef.current = true;
|
||||||
|
setLoadingSlots(true);
|
||||||
|
fetch(`${apiBase}/timetable/slots`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'ok' && Array.isArray(data.slots) && data.slots.length > 0) {
|
||||||
|
const g = emptyGrid();
|
||||||
|
for (const slot of data.slots) {
|
||||||
|
if (g[slot.day_of_week]) {
|
||||||
|
g[slot.day_of_week][slot.period_code] = slot.subject_class || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGrid(g);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingSlots(false));
|
||||||
|
}, [open, localTimetableId, accessToken, apiBase, loadingSlots]);
|
||||||
|
|
||||||
|
const setCell = (day: string, code: string, value: string) => {
|
||||||
|
setGrid(prev => ({ ...prev, [day]: { ...prev[day], [code]: value } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!accessToken || !localTimetableId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const slots = [];
|
||||||
|
for (const day of DAYS) {
|
||||||
|
for (const period of lessonPeriods) {
|
||||||
|
const cls = (grid[day]?.[period.code] || '').trim();
|
||||||
|
if (cls) {
|
||||||
|
slots.push({
|
||||||
|
day_of_week: day,
|
||||||
|
period_code: period.code,
|
||||||
|
subject_class: cls,
|
||||||
|
start_time: period.start_time,
|
||||||
|
end_time: period.end_time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await fetch(`${apiBase}/timetable/slots`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ timetable_id: localTimetableId, slots }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
onComplete(localTimetableId);
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Save failed');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const busy = initializing || loadingSlots || saving;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>
|
||||||
|
{isEditing ? 'Edit My Timetable' : 'Set Up My Timetable'}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{(initializing || loadingSlots) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{initializing ? 'Preparing your timetable…' : 'Loading existing classes…'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!initializing && !loadingSlots && localTimetableId && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
||||||
|
Enter your class codes for each lesson slot (leave blank if free)
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>Period</TableCell>
|
||||||
|
{DAYS.map(d => (
|
||||||
|
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 110 }}>
|
||||||
|
{d}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{lessonPeriods.map(period => (
|
||||||
|
<TableRow key={period.code}>
|
||||||
|
<TableCell>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
{period.code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||||
|
{period.start_time}–{period.end_time}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
{DAYS.map(day => (
|
||||||
|
<TableCell key={day} align="center" sx={{ p: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="—"
|
||||||
|
value={grid[day]?.[period.code] || ''}
|
||||||
|
onChange={e => setCell(day, period.code, e.target.value)}
|
||||||
|
inputProps={{
|
||||||
|
style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' },
|
||||||
|
}}
|
||||||
|
sx={{ width: 96 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={handleClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy || !localTimetableId}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={18} /> : 'Save Timetable'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user