Compare commits

..

No commits in common. "b0b2a7f2c3c1884bc102c06ed3d36c5b56c4e3b6" and "03452582476fcd6e1e9cbffb7203e0f6e5b4c57d" have entirely different histories.

31 changed files with 2113 additions and 3305 deletions

View File

@ -1,26 +1,12 @@
class AudioProcessor extends AudioWorkletProcessor { class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._buffer = new Float32Array(4096);
this._bufferIndex = 0;
}
process(inputs) { process(inputs) {
const input = inputs[0]; const input = inputs[0];
if (input.length > 0) { if (input.length > 0) {
const samples = input[0]; const audioData = input[0];
for (let i = 0; i < samples.length; i++) { this.port.postMessage(audioData);
this._buffer[this._bufferIndex++] = samples[i];
if (this._bufferIndex >= 4096) {
// Transfer ownership (zero-copy) to main thread
this.port.postMessage(this._buffer.buffer, [this._buffer.buffer]);
this._buffer = new Float32Array(4096);
this._bufferIndex = 0;
}
}
} }
return true; return true;
} }
} }
registerProcessor('audio-processor', AudioProcessor); registerProcessor('audio-processor', AudioProcessor);

View File

@ -4,23 +4,31 @@ 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>
<TLDrawProvider> <NeoUserProvider>
<AppRoutes /> <NeoInstituteProvider>
</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;

View File

@ -1,121 +1,458 @@
import React, { useState } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { import {
IconButton, Tooltip, Box, Menu, MenuItem, IconButton,
ListItemIcon, ListItemText, Chip, styled, Tooltip,
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,
Home as HomeIcon, School as SchoolIcon,
CalendarToday, Person as PersonIcon,
DateRange, AccountCircle as AccountCircleIcon,
Event, CalendarToday as CalendarIcon,
WorkspacesOutlined, School as TeachingIcon,
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: 6px; gap: 8px;
height: 100%; height: 100%;
overflow: hidden;
`; `;
function getNodeIcon(nodeType: string) { const NavigationControls = styled(Box)`
switch (nodeType) { display: flex;
case 'User': return <HomeIcon fontSize="small" />; align-items: center;
case 'CalendarYear': return <CalendarToday fontSize="small" />; gap: 4px;
case 'CalendarMonth': return <DateRange fontSize="small" />; `;
case 'CalendarDay': return <Event fontSize="small" />;
default: return <WorkspacesOutlined fontSize="small" />; const ContextToggleContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0.5),
gap: theme.spacing(0.5),
'& .button-label': {
'@media (max-width: 500px)': {
display: 'none'
}
} }
} }));
const ContextToggleButton = styled(Button, {
shouldForwardProp: (prop) => prop !== 'active'
})<{ active?: boolean }>(({ theme, active }) => ({
minWidth: 0,
padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius,
backgroundColor: active ? theme.palette.primary.main : 'transparent',
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
textTransform: 'none',
transition: theme.transitions.create(['background-color', 'color'], {
duration: theme.transitions.duration.shorter,
}),
'&:hover': {
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
},
'@media (max-width: 500px)': {
padding: theme.spacing(0.5),
}
}));
export const GraphNavigator: React.FC = () => { export const GraphNavigator: React.FC = () => {
const { context, goBack, goForward, isLoading } = useNavigationStore(); const {
context,
switchContext,
goBack,
goForward,
isLoading
} = useNavigationStore();
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null); const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [availableWidth, setAvailableWidth] = useState<number>(0);
const { history } = context; useEffect(() => {
const canGoBack = history.currentIndex > 0; const calculateAvailableSpace = () => {
const canGoForward = history.currentIndex < history.nodes.length - 1; if (!rootRef.current) return;
const currentNode = context.node;
// 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 handleHistoryClick = (e: React.MouseEvent<HTMLElement>) => setHistoryMenuAnchor(e.currentTarget);
const handleHistoryClose = () => setHistoryMenuAnchor(null);
const handleHistoryItemClick = (index: number) => { const handleHistoryItemClick = (index: number) => {
const delta = index - history.currentIndex; const {currentIndex} = context.history;
if (delta < 0) for (let i = 0; i < -delta; i++) goBack(); const steps = index - currentIndex;
else if (delta > 0) for (let i = 0; i < delta; i++) goForward();
if (steps < 0) {
for (let i = 0; i < -steps; i++) {
goBack();
}
} else if (steps > 0) {
for (let i = 0; i < steps; i++) {
goForward();
}
}
handleHistoryClose(); handleHistoryClose();
}; };
const handleContextChange = useCallback(async (newContext: BaseContext) => {
try {
// Check if trying to access institute contexts without worker database
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
return;
}
// Check if trying to access profile contexts without user database
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
return;
}
logger.debug('navigation', '🔄 Changing main context', {
from: context.main,
to: newContext,
userDbName,
workerDbName
});
// Get default view for new context
const defaultView = getDefaultViewForContext(newContext);
// Use unified context switch with both base and extended contexts
await switchContext({
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
base: newContext,
extended: defaultView,
skipBaseContextLoad: false
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to change context:', error);
}
}, [context.main, switchContext, userDbName, workerDbName]);
// Helper function to get default view for a context
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
switch (context) {
case 'calendar':
return 'overview';
case 'teaching':
return 'overview';
case 'school':
return 'overview';
case 'department':
return 'overview';
case 'class':
return 'overview';
default:
return 'overview';
}
};
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
setContextMenuAnchor(event.currentTarget);
};
const handleContextSelect = useCallback(async (context: BaseContext) => {
setContextMenuAnchor(null);
try {
// Use unified context switch with both base and extended contexts
const contextDef = NAVIGATION_CONTEXTS[context];
const defaultExtended = contextDef?.views[0]?.id;
await switchContext({
base: context,
extended: defaultExtended
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to select context:', error);
}
}, [switchContext, userDbName, workerDbName]);
const getContextItems = useCallback(() => {
if (context.main === 'profile') {
return [
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
];
} else {
return [
{ id: 'school', label: 'School', icon: BusinessIcon },
{ id: 'department', label: 'Department', icon: DepartmentIcon },
{ id: 'class', label: 'Class', icon: ClassIcon },
];
}
}, [context.main]);
const getContextIcon = useCallback((contextType: string) => {
switch (contextType) {
case 'profile':
return <AccountCircleIcon />;
case 'calendar':
return <CalendarIcon />;
case 'teaching':
return <TeachingIcon />;
case 'school':
return <BusinessIcon />;
case 'department':
return <DepartmentIcon />;
case 'class':
return <ClassIcon />;
default:
return <AccountCircleIcon />;
}
}, []);
const isDisabled = !isNeoUserInitialized || isLoading;
const { history } = context;
const canGoBack = history.currentIndex > 0;
const canGoForward = history.currentIndex < history.nodes.length - 1;
return ( return (
<NavigationRoot> <NavigationRoot ref={rootRef}>
<Tooltip title="Back"> <NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
<span> <Tooltip title="Back">
<IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small"> <span>
<ArrowBackIcon fontSize="small" /> <IconButton
</IconButton> onClick={goBack}
</span> disabled={!canGoBack || isDisabled}
</Tooltip> size="small"
>
<ArrowBackIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="History"> <Tooltip title="History">
<span> <span>
<IconButton <IconButton
onClick={handleHistoryClick} onClick={handleHistoryClick}
disabled={!history.nodes.length} disabled={!history.nodes.length || isDisabled}
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 onClick={goForward} disabled={!canGoForward || isLoading} size="small"> <IconButton
<ArrowForwardIcon fontSize="small" /> onClick={goForward}
</IconButton> disabled={!canGoForward || isDisabled}
</span> size="small"
</Tooltip> >
<ArrowForwardIcon fontSize="small" />
{currentNode && ( </IconButton>
<Chip </span>
size="small" </Tooltip>
icon={getNodeIcon(currentNode.type)} </NavigationControls>
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={{ vertical: 'bottom', horizontal: 'center' }} anchorOrigin={{
transformOrigin={{ vertical: 'top', horizontal: 'center' }} vertical: 'bottom',
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 sx={{ minWidth: 32 }}> <ListItemIcon>
{getNodeIcon(node.type)} {getContextIcon(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>
); );
}; };

View File

@ -1,225 +0,0 @@
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>
);
}

View File

@ -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,7 +20,6 @@ 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 () => {},
@ -32,12 +31,9 @@ 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 [accessToken, setAccessToken] = useState<string | null>(null); const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires
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);
@ -56,13 +52,20 @@ 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,
school_id: null, user_db_name: userDbName,
school_db_name: schoolDbName,
created_at: supabaseUser.created_at, created_at: supabaseUser.created_at,
updated_at: supabaseUser.updated_at updated_at: supabaseUser.updated_at
}; };
@ -71,82 +74,53 @@ 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 });
if (event === 'SIGNED_IN') { switch (event) {
persistSession(session ?? null); case 'INITIAL_SESSION':
if (session?.user) { case 'SIGNED_IN':
try { case 'TOKEN_REFRESHED': {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); persistSession(session ?? null);
setUser(resolvedUser); if (session?.user) {
setUserRole(role); try {
setAccessToken(session.access_token ?? null); const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
triggerUserInit(session.access_token); setUser(resolvedUser);
} catch (buildError) { setUserRole(role);
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); } catch (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'));
} }
} else { // Always clear loading after the first auth event resolves
setLoading(false);
break;
}
case 'SIGNED_OUT': {
persistSession(null);
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null); setLoading(false);
break;
} }
setLoading(false); default:
return; break;
}
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, triggerUserInit]); }, [buildUserFromSupabase, persistSession]);
const signIn = async (email: string, password: string) => { const signIn = async (email: string, password: string) => {
try { try {
@ -166,7 +140,6 @@ 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 });
@ -200,7 +173,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
value={{ value={{
user, user,
user_role, user_role,
accessToken,
loading, loading,
error, error,
signIn, signIn,

View File

@ -3,6 +3,7 @@ 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';
@ -130,7 +131,7 @@ const NeoUserContext = createContext<NeoUserContextType>({
}); });
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user, accessToken } = useAuth(); const { user } = useAuth();
const { profile, isInitialized: isUserInitialized } = useUser(); const { profile, isInitialized: isUserInitialized } = useUser();
const navigationStore = useNavigationStore(); const navigationStore = useNavigationStore();
@ -214,9 +215,12 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
// Inject auth into navigation store so Supabase queries work // Set database names
if (user?.id && accessToken) { const userDb = profile.user_db_name || (user?.email ?
navigationStore.setAuthInfo(accessToken, user.id); DatabaseNameService.getStoredUserDatabase() || null : null);
if (!userDb) {
throw new Error('No user database name available');
} }
// Initialize user node in profile context // Initialize user node in profile context
@ -232,7 +236,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
main: 'profile', main: 'profile',
base: 'profile', base: 'profile',
extended: 'overview' extended: 'overview'
}, null, null), }, userDb, profile.school_db_name),
switchTimeout switchTimeout
]); ]);
@ -267,9 +271,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 — userDbName signals auth availability for UI guards // Set final state
setUserDbName(user?.id || null); setUserDbName(userDb);
setWorkerDbName(null); setWorkerDbName(profile.school_db_name);
setIsInitialized(true); setIsInitialized(true);
setIsLoading(false); setIsLoading(false);
initializationRef.current.isComplete = true; initializationRef.current.isComplete = true;
@ -290,13 +294,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 (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'day' extended: 'day'
}, null, null); }, userDbName, workerDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -330,13 +334,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToWeek = async (id: string) => { const navigateToWeek = async (id: string) => {
if (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'week' extended: 'week'
}, null, null); }, userDbName, workerDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -370,13 +374,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToMonth = async (id: string) => { const navigateToMonth = async (id: string) => {
if (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'month' extended: 'month'
}, null, null); }, userDbName, workerDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -410,13 +414,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToYear = async (id: string) => { const navigateToYear = async (id: string) => {
if (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'year' extended: 'year'
}, null, null); }, userDbName, workerDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -451,13 +455,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 (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'timetable' extended: 'timetable'
}, null, null); }, userDbName, workerDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -488,13 +492,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToJournal = async (id: string) => { const navigateToJournal = async (id: string) => {
if (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'journal' extended: 'journal'
}, null, null); }, userDbName, workerDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -525,13 +529,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToPlanner = async (id: string) => { const navigateToPlanner = async (id: string) => {
if (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'planner' extended: 'planner'
}, null, null); }, userDbName, workerDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -562,14 +566,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToClass = async (id: string) => { const navigateToClass = async (id: string) => {
if (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'classes' extended: 'classes'
}, null, null); }, userDbName, workerDbName);
await navigationStore.navigate(id, ''); await navigationStore.navigate(id, userDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@ -600,14 +604,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToLesson = async (id: string) => { const navigateToLesson = async (id: string) => {
if (!user?.id) return; if (!userDbName) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'lessons' extended: 'lessons'
}, null, null); }, userDbName, workerDbName);
await navigationStore.navigate(id, ''); await navigationStore.navigate(id, userDbName);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {

View File

@ -4,6 +4,8 @@ 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 {
@ -110,23 +112,33 @@ 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 immediately (no spinner on refresh). // Fast-path: build profile from auth metadata + localStorage immediately.
// 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 || ''),
school_id: null, user_db_name: String(fastUserDb || ''),
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'); logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata, no spinner');
} }
// 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).
@ -160,15 +172,80 @@ 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 || ''),
school_id: (profileRow?.school_id as string) ?? null, user_db_name: String(userDbName || ''),
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
}; };
@ -191,7 +268,9 @@ 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) {
@ -216,14 +295,22 @@ 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',
school_id: null, user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id),
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);

View File

@ -1,37 +0,0 @@
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 };
}

View File

@ -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 { useAuth } from '../../../contexts/AuthContext'; import { supabase } from '../../../supabaseClient';
type Manifest = { type Manifest = {
bucket: string; bucket: string;
@ -41,7 +41,6 @@ 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');
@ -54,9 +53,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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, accessToken]); }, [API_BASE]);
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 || '';
@ -223,7 +222,7 @@ export const CCBundleViewer: React.FC<{
setManifest(null); setManifest(null);
if (combinedBundles && combinedBundles.length > 0) { if (combinedBundles && combinedBundles.length > 0) {
try { try {
const token = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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}` } });
@ -240,7 +239,7 @@ export const CCBundleViewer: React.FC<{
} }
if (!bundleId) return; if (!bundleId) return;
try { try {
const token = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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();
@ -267,7 +266,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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;
@ -349,7 +348,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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());

View File

@ -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 { useAuth } from '../../../contexts/AuthContext'; import { supabase } from '../../../supabaseClient';
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,7 +43,6 @@ 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 }>>([]);
@ -185,7 +184,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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
if (mRes.ok) { if (mRes.ok) {
const m: PageImagesManifest = await mRes.json(); const m: PageImagesManifest = await mRes.json();
@ -200,7 +199,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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
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();
@ -217,7 +216,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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
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();
@ -268,7 +267,7 @@ export const CCDoclingViewer: React.FC<{
setPageObjectUrl(cached); setPageObjectUrl(cached);
return; return;
} }
const token = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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
@ -370,7 +369,6 @@ 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);
@ -378,7 +376,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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();

View File

@ -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 { useAuth } from '../../../contexts/AuthContext'; import { supabase } from '../../../supabaseClient';
type CanonicalDoclingConfig = { type CanonicalDoclingConfig = {
pipeline: 'standard' | 'vlm' | 'asr'; pipeline: 'standard' | 'vlm' | 'asr';
@ -46,7 +46,6 @@ 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');
@ -149,7 +148,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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();
@ -226,14 +225,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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
if (!jsonRes.ok) return; if (!jsonRes.ok) return;
const doc = await jsonRes.json(); const doc = await jsonRes.json();
@ -243,7 +242,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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
if (smRes.ok) { if (smRes.ok) {
const sm = await smRes.json(); const sm = await smRes.json();
@ -487,7 +486,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const body: CanonicalDoclingRequest = { const body: CanonicalDoclingRequest = {
use_split_map: selectedSectionId === 'full' ? autoSplit : false, use_split_map: selectedSectionId === 'full' ? autoSplit : false,
config: { config: {

View File

@ -11,8 +11,7 @@ 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 CalendarViewMonth from '@mui/icons-material/CalendarViewMonth'; import { supabase } from '../../../supabaseClient';
import { useAuth } from '../../../contexts/AuthContext';
// Types // Types
type PageImagesManifest = { type PageImagesManifest = {
@ -67,7 +66,6 @@ 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);
@ -96,7 +94,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
setError(null); setError(null);
try { try {
const token = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
// 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`, {
@ -200,7 +198,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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;

View File

@ -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 { useAuth } from '../../../contexts/AuthContext'; import { supabase } from '../../../supabaseClient';
type PageImagesManifest = { type PageImagesManifest = {
version: number; version: number;
@ -54,7 +54,6 @@ 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);
@ -74,7 +73,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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
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();
@ -83,14 +82,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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
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 ${accessToken || ''}` } headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
}); });
if (jsonRes.ok) { if (jsonRes.ok) {
const outJson = await jsonRes.json(); const outJson = await jsonRes.json();
@ -119,7 +118,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 = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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();
@ -151,7 +150,7 @@ export const CCFileDetailPanel: React.FC<{
<IconButton size="small" onClick={async () => { <IconButton size="small" onClick={async () => {
try { try {
setShowAdmin(true); setShowAdmin(true);
const token = accessToken || ''; const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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);

View File

@ -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,8 +46,6 @@ 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,
@ -57,6 +55,8 @@ 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,7 +114,6 @@ 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');
@ -123,7 +122,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: null, dbName: user.user_db_name,
node: context.node, node: context.node,
node_storage_path: nodeStoragePath, node_storage_path: nodeStoragePath,
user_type: user.user_type, user_type: user.user_type,
@ -132,14 +131,12 @@ export default function SinglePlayerPage() {
await NavigationSnapshotService.loadNodeSnapshotFromDatabase( await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath, nodeStoragePath,
accessToken || '', user.user_db_name,
newStore, newStore,
setLoadingState, setLoadingState,
undefined, undefined, // sharedStore
editorRef.current || undefined editorRef.current || undefined // editor
); );
// 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', {
@ -155,7 +152,7 @@ export default function SinglePlayerPage() {
let isAutoSaving = false; let isAutoSaving = false;
newStore.listen(() => { newStore.listen(() => {
if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) { if (snapshotServiceRef.current && context.node && 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');
@ -181,6 +178,8 @@ 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');
} }
}); });
@ -254,16 +253,11 @@ export default function SinglePlayerPage() {
try { try {
setLoadingState({ status: 'loading', error: '' }); setLoadingState({ status: 'loading', error: '' });
if (context.node.type !== 'workspace') { // Center the node
try { const nodeData = await loadNodeData(context.node);
const nodeData = await loadNodeData(context.node); await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
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) {
@ -303,17 +297,12 @@ 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 (load/save snapshot) // Handle navigation in snapshot service
await snapshotService.handleNavigationStart(previousNode, currentNode); await snapshotService.handleNavigationStart(previousNode, currentNode);
if (currentNode.type !== 'workspace') { // Center the node on canvas
try { const nodeData = await loadNodeData(currentNode);
const nodeData = await loadNodeData(currentNode); await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
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) {
@ -326,17 +315,7 @@ export default function SinglePlayerPage() {
}; };
handleNodeChange(); handleNodeChange();
}, [context.node, context.history, store]); }, [context.node, context.history, store, isInitialLoad]);
// 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(() => {
@ -483,6 +462,9 @@ 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) && (
@ -545,7 +527,6 @@ 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);
@ -584,21 +565,68 @@ const getNodeStoragePath = (node: NavigationNode): string | null => {
}; };
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => { const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
if (!node?.id) throw new Error('Node parameter is required'); // Validate the node parameter
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) throw new Error(`Node ${node.id} is missing node_storage_path`); if (!nodeStoragePath) {
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
});
const theme = getThemeFromLabel(node.type); try {
return { // 1. Always fetch fresh data
title: node.label || node.type || '', // Create a temporary node object with the correct structure for the service
w: 500, const normalizedNode = {
h: 350, ...node,
state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null }, node_storage_path: nodeStoragePath
headerColor: theme.headerColor, };
backgroundColor: theme.backgroundColor,
isLocked: false, const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
__primarylabel__: node.type, const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
uuid_string: node.id,
node_storage_path: nodeStoragePath, if (!fetchedData?.node_data) {
}; 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;
}
}; };

View File

@ -3,6 +3,7 @@ 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;
@ -10,7 +11,8 @@ export interface CCUser {
user_type: string; user_type: string;
username: string; username: string;
display_name: string; display_name: string;
school_id?: string | null; user_db_name: string;
school_db_name: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@ -42,13 +44,28 @@ 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,
school_id: null, user_db_name: userDbName,
school_db_name: schoolDbName,
created_at: user.created_at, created_at: user.created_at,
updated_at: user.updated_at, updated_at: user.updated_at,
}; };

View File

@ -6,6 +6,7 @@ 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';
@ -86,6 +87,14 @@ 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,
@ -101,6 +110,11 @@ 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,

View File

@ -1,52 +1,27 @@
// 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 SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string; const EMPTY_NODE: NavigationNode = {
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string; id: '',
const BUCKET = 'cc.users'; node_storage_path: '',
type: '',
async function storageGet(path: string, accessToken: string): Promise<unknown | null> { label: ''
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;
@ -58,21 +33,24 @@ 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
});
} }
setAccessToken(token: string): void { private static replaceBackslashes(input: string | undefined): string {
this._accessToken = token; return input ? input.replace(/\\/g, '/') : '';
} }
static async loadNodeSnapshotFromDatabase( static async loadNodeSnapshotFromDatabase(
nodePath: string, nodePath: string,
accessToken: string, dbName: string,
store: TLStore, store: TLStore,
setLoadingState: (state: LoadingState) => void, setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService, sharedStore?: SharedStoreService,
@ -80,102 +58,248 @@ 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 });
const snapshot = await storageGet(nodePath, accessToken); logger.info('snapshot-service', '📂 Loading file from path', {
path: nodePath,
db_name: dbName
});
if (!snapshot) { const response = await axios.get(
logger.debug('snapshot-service', ' No snapshot found at path — clearing canvas', { nodePath }); '/database/tldraw_supabase/get_tldraw_node_file', {
// Clear all shapes so the canvas is blank for this new node params: {
if (editor) { path: this.replaceBackslashes(nodePath),
const shapeIds = [...editor.getCurrentPageShapeIds()]; db_name: dbName
if (shapeIds.length > 0) {
editor.deleteShapes(shapeIds);
} }
} }
setLoadingState({ status: 'ready', error: '' }); );
return;
}
const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown }; const snapshot = response.data;
if (!snap.document || !snap.session) { logger.debug('snapshot-service', '🔍 Snapshot data received', {
logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath }); hasSnapshot: !!snapshot,
setLoadingState({ status: 'ready', error: '' }); hasDocument: !!snapshot?.document,
return; hasSession: !!snapshot?.session,
} hasSchemaVersion: !!snapshot?.schemaVersion,
schemaVersion: snapshot?.schemaVersion,
if (sharedStore) { snapshotKeys: snapshot ? Object.keys(snapshot) : []
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) {
logger.error('snapshot-service', '❌ Failed to load snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
}); });
setLoadingState({
status: 'error', if (snapshot && snapshot.document && snapshot.session) {
error: error instanceof Error ? error.message : 'Failed to load snapshot', 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' });
}
} catch (error) {
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
error: error instanceof Error ? error.message : 'Unknown error'
});
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load file'
}); });
} }
} }
static async saveNodeSnapshotToDatabase( static async saveNodeSnapshotToDatabase(
nodePath: string, nodePath: string,
accessToken: string, dbName: string,
store: TLStore store: TLStore
): Promise<void> { ): Promise<void> {
try { try {
logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath }); logger.info('snapshot-service', '💾 Saving snapshot to database', {
path: nodePath,
db_name: dbName
});
const snapshot = getSnapshot(store); const snapshot = getSnapshot(store);
await storagePut(nodePath, accessToken, snapshot);
logger.debug('snapshot-service', '✅ Snapshot saved successfully'); // Debug: Log what we're saving
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) return; if (!this.currentNodePath || this.currentNodePath !== nodePath) {
if (!this._accessToken) { logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped'); currentPath: this.currentNodePath,
savePath: nodePath
});
return; return;
} }
try { try {
this.isSaving = true; this.isSaving = true;
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store); const user = storageService.get(StorageKeys.USER);
logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath }); if (!user) {
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 {
@ -183,77 +307,137 @@ export class NavigationSnapshotService {
} }
} }
private async loadSnapshotForNode(node: { node_storage_path: string }): Promise<void> { private async loadSnapshotForNode(node: NavigationNode): 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,
this._accessToken, dbName,
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, undefined, // sharedStore
this.editor || undefined this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot
); );
} 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: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> { async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
if (!toNode) return; if (!toNode) {
if (this.debounceTimeout) clearTimeout(this.debounceTimeout); logger.warn('snapshot-service', '⚠️ Cannot navigate to null node');
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, toNode); await this.executeNavigation(fromNode || EMPTY_NODE, 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); }, 100); // 100ms debounce
}); });
} }
private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> { private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
if (this.isSaving || this.isLoading) { try {
this.pendingOperation = { logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
save: fromNode?.node_storage_path, from: fromNode.node_storage_path,
load: toNode.node_storage_path, to: toNode.node_storage_path,
}; currentPath: this.currentNodePath
return; });
}
this.currentNodePath = null; // If we're already in a navigation operation, queue this one
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;
}
if (toNode.node_storage_path) { // Clear the store before loading new snapshot
await this.loadSnapshotForNode(toNode); logger.debug('snapshot-service', '🔄 Clearing store');
} this.currentNodePath = null;
logger.debug('snapshot-service', '🧹 Cleared current node path');
if (this.pendingOperation) { // Load the new node's snapshot
const op = this.pendingOperation; if (toNode.node_storage_path) {
this.pendingOperation = null; await this.loadSnapshotForNode(toNode);
await this.handleNavigationStart( logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
op.save ? { node_storage_path: op.save } : null, nodePath: toNode.node_storage_path
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 {
@ -263,11 +447,14 @@ 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');
} }
} }

View File

@ -1,45 +1,32 @@
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: { nodes: [], currentIndex: -1 } history: {
nodes: [],
currentIndex: -1
}
}; };
function getDefaultBaseForMain(main: MainContext): BaseContext { function getDefaultBaseForMain(main: MainContext): BaseContext {
@ -51,288 +38,402 @@ 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) {
const isValid = newState.main === 'profile' // Ensure base context matches main context
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 const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => { export interface NavigationActions {
const pgFetch = async <T = unknown>( // Context Navigation
method: 'GET' | 'POST' | 'PATCH' | 'DELETE', setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
table: string, setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
): Promise<T | null> => {
const token = get()._accessToken; // Node Navigation
if (!token) throw new Error('pgFetch: no access token'); navigate: (nodeId: string, dbName: string) => Promise<void>;
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`; navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`, // History Navigation
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY, goBack: () => void;
'Content-Type': 'application/json', goForward: () => void;
// Utility Methods
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
}
export interface NavigationState {
context: {
main: NodeContext;
base: NodeContext;
extended?: string;
node: NavigationNode;
history: {
nodes: NavigationNode[];
currentIndex: number;
}; };
if (options.prefer) headers['Prefer'] = options.prefer;
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>;
}; };
// ... rest of the state interface ...
}
const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => { export const useNavigationStore = create<NavigationStore>((set, get) => ({
const userId = get()._userId; context: initialState,
if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID'); isLoading: false,
error: null,
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', { switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => {
query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`, try {
}); // Check if we have the necessary database connections
if (contextSwitch.main === 'profile' && !userDbName) {
logger.error('navigation-context', '❌ User database connection not initialized');
set({
error: 'User database connection not initialized',
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;
}
if (rooms && rooms.length > 0) { logger.debug('navigation-context', '🔄 Starting context switch', {
const room = rooms[0]; from: {
return { main: get().context.main,
id: room.id, base: get().context.base,
node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`, extended: contextSwitch.extended,
label: room.name, nodeId: get().context.node?.id
type: 'workspace', },
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', {
previous: currentState.main,
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
});
} }
},
const storagePath = `${userId}/workspaces/${contextType}_default.json`; goBack: () => {
const room = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', { const currentState = get().context;
body: { if (currentState.history.currentIndex > 0) {
user_id: userId, const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`, const node = getCurrentHistoryNode(newHistory);
context_type: contextType, set({
is_default: true, context: {
storage_path: storagePath, ...currentState,
}, node,
prefer: 'return=representation', history: newHistory
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',
};
};
return {
_accessToken: null,
_userId: null,
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
context: initialState,
isLoading: false,
error: null,
switchContext: async (contextSwitch: UnifiedContextSwitch, _userDbName: string | null = null, _workerDbName: string | null = null) => {
if (!get()._accessToken || !get()._userId) {
logger.warn('navigation-context', '⚠️ switchContext called without auth — skipping');
return;
}
try {
set({ isLoading: true, error: null });
const currentState = get().context;
let newState: NavigationContextState = { ...currentState, node: null };
if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main);
}
} }
if (contextSwitch.base) { });
newState = validateContextTransition(newState, { base: contextSwitch.base }); }
},
goForward: () => {
const currentState = get().context;
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
} }
});
}
},
const targetContext = contextSwitch.base || setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
contextSwitch.extended || try {
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base); // Use switchContext instead of direct implementation
const defaultNode = await getOrCreateDefaultRoom(targetContext);
const newHistory = addToHistory(currentState.history, defaultNode);
set({
context: { ...newState, node: defaultNode, history: newHistory },
isLoading: false,
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); 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) => { setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ base }, userDbName, workerDbName); 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) => { setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ extended }, userDbName, workerDbName); 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) => { navigate: async (nodeId: string, dbName: string) => {
try { try {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
if (!get()._accessToken) { set({ isLoading: false }); return; }
// Check if we already have this node in history
const currentState = get().context; const currentState = get().context;
const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
if (existingIndex !== -1) {
const newHistory = navigateHistory(currentState.history, existingIndex); // If node exists in history, just navigate to it
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false }); if (existingNodeIndex !== -1) {
return; logger.debug('navigation', '📍 Navigating to existing node in history', {
} nodeId,
historyIndex: existingNodeIndex,
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', { currentIndex: currentState.history.currentIndex
query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`, });
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
}); });
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 {
set({ isLoading: true, error: null }); // Fetch new node data
const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', { const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName);
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`, if (!nodeData) {
}); throw new Error(`Node not found: ${nodeId}`);
let room: WhiteboardRoom;
if (existing && existing.length > 0) {
room = existing[0];
} else {
const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`;
const created = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
body: {
user_id: userId,
name: neoNode.label,
context_type: neoNode.node_type.toLowerCase(),
is_default: false,
storage_path: storagePath,
neo4j_node_id: neoNode.neo4j_node_id,
neo4j_db_name: neoNode.neo4j_db_name,
node_type: neoNode.node_type,
},
prefer: 'return=representation',
single: true,
});
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 });
} }
},
}; const node: NavigationNode = {
}); id: nodeId,
node_storage_path: nodeData.node_data.node_storage_path || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
type: nodeData.node_type
};
logger.debug('navigation', '📍 Adding new node to history', {
nodeId: node.id,
type: node.type,
node_storage_path: node.node_storage_path
});
// Add to history and update state
const newHistory = addToHistory(currentState.history, node);
set({
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
}
});
}
}
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
});
}
}
}));

View File

@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { supabase } from '../supabaseClient';
export interface TranscriptionSegment { export interface TranscriptionSegment {
text: string; text: string;
@ -22,12 +23,6 @@ export interface TranscriptionSession {
segment_count: number; segment_count: number;
} }
export interface ServerSegment {
text: string;
start: number;
end: number;
}
export interface TimetablePeriod { export interface TimetablePeriod {
period_id: string | null; period_id: string | null;
event_type: string | null; event_type: string | null;
@ -40,8 +35,6 @@ export interface LLMConfig {
provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google'; provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google';
model: string; model: string;
apiKey: string; apiKey: string;
baseUrl?: string; // for Ollama: e.g. https://ollama.kevlarai.com
whisperModel?: string; // faster-whisper model size sent to WhisperLive
} }
export type ExportFormat = 'srt' | 'txt' | 'json'; export type ExportFormat = 'srt' | 'txt' | 'json';
@ -79,8 +72,6 @@ function loadLLMConfig(): LLMConfig {
provider: 'openai', provider: 'openai',
model: '', model: '',
apiKey: '', apiKey: '',
baseUrl: '',
whisperModel: 'large-v3',
}; };
} }
@ -99,9 +90,8 @@ interface TranscriptionState {
activeSession: TranscriptionSession | null; activeSession: TranscriptionSession | null;
// Live feed // Live feed
completedSegments: TranscriptionSegment[]; // segments that scrolled off the server window (archived) completedSegments: TranscriptionSegment[];
serverWindow: ServerSegment[]; // the current server-provided segment window (last N) currentSegment: TranscriptionSegment | null;
currentSegment: TranscriptionSegment | null; // the live (last) segment if still being refined
// Canvas event buffer (flushed to API every 5s) // Canvas event buffer (flushed to API every 5s)
pendingCanvasEvents: any[]; pendingCanvasEvents: any[];
@ -129,15 +119,9 @@ interface TranscriptionState {
keywordWatches: KeywordWatch[]; keywordWatches: KeywordWatch[];
keywordMatches: KeywordMatch[]; keywordMatches: KeywordMatch[];
// Auth (set by panel via setAuthInfo after SIGNED_IN)
_accessToken: string | null;
_userId: string | null;
setAuthInfo: (token: string | null, userId: string | null) => void;
// Actions // Actions
startSession: (timetableTag?: TimetablePeriod) => Promise<void>; startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
stopSession: () => Promise<void>; stopSession: () => Promise<void>;
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>; saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>;
resetSession: () => void; resetSession: () => void;
tickElapsed: () => void; tickElapsed: () => void;
@ -167,577 +151,411 @@ interface TranscriptionState {
clearKeywordMatches: () => void; clearKeywordMatches: () => void;
} }
export const useTranscriptionStore = create<TranscriptionState>((set, get) => { export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
// Direct PostgREST fetch — uses stored _accessToken, no GoTrueClient lock. isRecording: false,
const pgFetch = async <T = any>( isConnecting: false,
method: 'GET' | 'POST' | 'PATCH' | 'DELETE', activeSession: null,
table: string, completedSegments: [],
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} currentSegment: null,
): Promise<T | null> => { pendingCanvasEvents: [],
const token = get()._accessToken; timetableContext: null,
if (!token) throw new Error('pgFetch: no access token'); wordCount: 0,
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`; elapsedSeconds: 0,
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`, // LLM config initialized from localStorage
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY, llmConfig: loadLLMConfig(),
'Content-Type': 'application/json',
}; // Summary state
if (options.prefer) headers['Prefer'] = options.prefer; summaryText: null,
if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json'; isGeneratingSummary: false,
const res = await fetch(url, { summaryError: null,
method,
headers, // Export state
...(options.body ? { body: JSON.stringify(options.body) } : {}), isExporting: false,
}); exportError: null,
if (!res.ok) {
const err = await res.text(); // Keyword state
throw new Error(`PostgREST ${res.status}: ${err}`); keywordWatches: [],
keywordMatches: [],
setTimetableContext: (context) => {
set({ timetableContext: context });
},
startSession: async (timetableTag?: TimetablePeriod) => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
// Create session in Supabase
try {
const user = await supabase.auth.getUser();
if (!user.data.user) {
console.error('No authenticated user');
return;
}
const sessionData = {
user_id: user.data.user.id,
title: timetableTag?.event_label || 'Untitled Session',
canvas_type: 'teaching-canvas',
timetable_period_id: timetableTag?.period_id || null,
timetable_event_type: timetableTag?.event_type || null,
timetable_event_label: timetableTag?.event_label || null,
auto_tagged: !!timetableTag,
};
const { data, error } = await supabase
.from('transcription_sessions')
.insert(sessionData)
.select()
.single();
if (error) {
console.error('Failed to create session:', error);
return;
}
set({ activeSession: data });
} catch (error) {
console.error('Error starting session:', error);
} }
if (res.status === 204) return null; },
return res.json() as Promise<T>;
};
return { stopSession: async () => {
isRecording: false, const { activeSession, completedSegments } = get();
isConnecting: false,
activeSession: null,
_accessToken: null,
_userId: null,
completedSegments: [],
serverWindow: [],
currentSegment: null,
pendingCanvasEvents: [],
timetableContext: null,
wordCount: 0,
elapsedSeconds: 0,
// LLM config initialized from localStorage
llmConfig: loadLLMConfig(),
// Summary state
summaryText: null,
isGeneratingSummary: false,
summaryError: null,
// Export state
isExporting: false,
exportError: null,
// Keyword state
keywordWatches: [],
keywordMatches: [],
setTimetableContext: (context) => {
set({ timetableContext: context });
},
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
startSession: async (timetableTag?: TimetablePeriod) => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
if (activeSession) {
try { try {
const { _userId: userId } = get(); await supabase
if (!userId) { .from('transcription_sessions')
console.error('No authenticated user'); .update({
return; ended_at: new Date().toISOString(),
} word_count: get().wordCount,
segment_count: completedSegments.length,
const sessionData = { })
user_id: userId, .eq('id', activeSession.id);
title: timetableTag?.event_label || 'Untitled Session',
canvas_type: 'teaching-canvas',
timetable_period_id: timetableTag?.period_id || null,
timetable_event_type: timetableTag?.event_type || null,
timetable_event_label: timetableTag?.event_label || null,
auto_tagged: !!timetableTag,
};
const data = await pgFetch<TranscriptionSession>('POST', 'transcription_sessions', {
body: sessionData,
prefer: 'return=representation',
single: true,
});
if (!data) {
console.error('Failed to create session: no data returned');
return;
}
set({ activeSession: data });
} catch (error) { } catch (error) {
console.error('Error starting session:', error); console.error('Failed to end session:', error);
} }
}, }
stopSession: async () => { set({
const { activeSession, currentSegment, completedSegments } = get(); isRecording: false,
isConnecting: false,
activeSession: null,
});
},
// The live segment (currentSegment) was never added to completedSegments — flush it now. saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
let newCompleted = [...completedSegments]; const { completedSegments, currentSegment, activeSession, wordCount } = get();
if (isFinal) {
// Final segment - move current to completed, clear current
const newCompleted = [...completedSegments];
if (currentSegment && currentSegment.text.trim()) { if (currentSegment && currentSegment.text.trim()) {
const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5); newCompleted.push({ ...currentSegment, isFinal: true });
if (!alreadyIn) {
const idx = newCompleted.length;
newCompleted.push({ ...currentSegment, isFinal: true });
if (activeSession) {
pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id,
sequence_index: idx,
text: currentSegment.text,
start_seconds: currentSegment.start,
end_seconds: currentSegment.end,
is_final: true,
},
}).catch(err => console.error('Failed to save live segment on stop:', err));
}
}
} }
const finalWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
if (activeSession) {
try {
await pgFetch('PATCH', 'transcription_sessions', {
query: `id=eq.${activeSession.id}`,
body: {
ended_at: new Date().toISOString(),
word_count: finalWordCount,
segment_count: newCompleted.length,
},
});
} catch (error) {
console.error('Failed to end session:', error);
}
}
set({
isRecording: false,
isConnecting: false,
activeSession: null,
completedSegments: newCompleted,
serverWindow: [],
currentSegment: null,
wordCount: finalWordCount,
});
},
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => {
const { completedSegments, activeSession } = get();
if (segments.length === 0) return;
// The server marks every finalized segment with completed=true and the live
// one with completed=false. Rather than relying on window-scroll detection
// (which can miss segments when the server creates several at once), we
// directly merge every completed segment from this message into the store.
// This guarantees no gaps: any segment the server says is complete is captured
// immediately, regardless of how many were created since the last message.
const serverCompleted = isLastLive ? segments.slice(0, -1) : segments;
let newCompleted = [...completedSegments];
const toSave: Array<{ seg: ServerSegment; idx: number }> = [];
for (const seg of serverCompleted) {
if (!seg.text.trim()) continue;
const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5);
if (existingIdx >= 0) {
// Server refined an existing segment — update text and end time in place.
newCompleted[existingIdx] = {
...newCompleted[existingIdx],
text: seg.text,
end: seg.end,
};
} else {
const newIdx = newCompleted.length;
newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end });
toSave.push({ seg, idx: newIdx });
}
}
// Keep sorted by start time so display order is always correct.
newCompleted.sort((a, b) => a.start - b.start);
// Persist and keyword-check only truly new segments.
if (toSave.length > 0) {
const elapsed = get().elapsedSeconds;
for (const { seg, idx } of toSave) {
if (activeSession) {
pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id,
sequence_index: idx,
text: seg.text,
start_seconds: seg.start,
end_seconds: seg.end,
is_final: true,
},
}).catch(err => console.error('Failed to save segment:', err));
}
get().checkSegmentForKeywords(seg.text, elapsed);
}
}
const lastSeg = segments[segments.length - 1];
const newCurrentSegment: TranscriptionSegment | null = isLastLive
? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end }
: null;
const newWordCount = newCompleted.reduce( const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0 0
); );
set({ set({
serverWindow: segments,
completedSegments: newCompleted, completedSegments: newCompleted,
currentSegment: newCurrentSegment, currentSegment: null,
wordCount: newWordCount, wordCount: newWordCount,
}); });
},
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { // Save to Supabase if session is active
const { completedSegments, currentSegment, activeSession } = get(); if (activeSession) {
try {
if (isFinal) { const sequenceIndex = newCompleted.length - 1;
// Deduplicate by start time: if a segment with this start already exists, update it await supabase.from('transcription_segments').insert({
// rather than appending. This prevents doubles when the stability timer fires and session_id: activeSession.id,
// the segment later appears in the server's finalized list with a slightly extended end. sequence_index: sequenceIndex,
const existingIdx = completedSegments.findIndex( text: text,
(s) => Math.abs(s.start - metadata.start) < 0.5 start_seconds: metadata.start,
); end_seconds: metadata.end,
is_final: true,
let newCompleted: TranscriptionSegment[];
let isNew: boolean;
if (existingIdx >= 0) {
newCompleted = completedSegments.map((s, i) =>
i === existingIdx ? { text, isFinal: true, ...metadata } : s
);
isNew = false;
} else {
newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
isNew = true;
}
const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
if (isNew && activeSession) {
try {
await pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id,
sequence_index: newCompleted.length - 1,
text,
start_seconds: metadata.start,
end_seconds: metadata.end,
is_final: true,
},
});
} catch (error) {
console.error('Failed to save segment:', error);
}
}
} else {
// In-progress segment. If the start time jumped to a new position, the previous
// live segment is done — auto-commit it before switching.
if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) {
const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }];
const autoWordCount = autoCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({ completedSegments: autoCompleted, wordCount: autoWordCount });
if (activeSession) {
pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id,
sequence_index: autoCompleted.length - 1,
text: currentSegment.text,
start_seconds: currentSegment.start,
end_seconds: currentSegment.end,
is_final: true,
},
}).catch(err => console.error('Failed to save auto-committed segment:', err));
}
}
set({ currentSegment: { text, isFinal: false, ...metadata } });
}
},
resetSession: () => {
set({
isRecording: false,
isConnecting: false,
completedSegments: [],
serverWindow: [],
currentSegment: null,
wordCount: 0,
elapsedSeconds: 0,
activeSession: null,
pendingCanvasEvents: [],
timetableContext: null,
keywordMatches: [],
});
},
tickElapsed: () => {
set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 }));
},
addCanvasEvent: (event) => {
set((state) => ({
pendingCanvasEvents: [...state.pendingCanvasEvents, event],
}));
},
flushCanvasEvents: async () => {
const { pendingCanvasEvents, activeSession } = get();
if (pendingCanvasEvents.length === 0) return;
const eventsToFlush = [...pendingCanvasEvents];
try {
for (const event of eventsToFlush) {
await pgFetch('POST', 'canvas_events', {
body: {
session_id: activeSession?.id || null,
user_id: get()._userId || '',
timestamp: new Date().toISOString(),
session_elapsed_seconds: event.sessionElapsedSeconds || null,
event_type: event.eventType,
event_payload: event.payload || {},
canvas_snapshot_url: event.snapshotUrl || null,
tldraw_page_id: event.pageId || null,
tldraw_shape_ids: event.shapeIds || null,
},
}); });
} catch (error) {
console.error('Failed to save segment:', error);
} }
set({ pendingCanvasEvents: [] });
} catch (error) {
console.error('Failed to flush canvas events:', error);
} }
}, } else {
// In-progress segment
set({ currentSegment: { text, isFinal: false, ...metadata } });
}
},
loadSessions: async (): Promise<TranscriptionSession[]> => { resetSession: () => {
try { set({
const { _userId: userId } = get(); isRecording: false,
if (!userId) return []; isConnecting: false,
completedSegments: [],
currentSegment: null,
wordCount: 0,
elapsedSeconds: 0,
activeSession: null,
pendingCanvasEvents: [],
timetableContext: null,
keywordMatches: [],
});
},
const data = await pgFetch<TranscriptionSession[]>('GET', 'transcription_sessions', { tickElapsed: () => {
query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`, set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 }));
},
addCanvasEvent: (event) => {
set((state) => ({
pendingCanvasEvents: [...state.pendingCanvasEvents, event],
}));
},
flushCanvasEvents: async () => {
const { pendingCanvasEvents, activeSession } = get();
if (pendingCanvasEvents.length === 0) return;
const eventsToFlush = [...pendingCanvasEvents];
try {
for (const event of eventsToFlush) {
await supabase.from('canvas_events').insert({
session_id: activeSession?.id || null,
user_id: (await supabase.auth.getUser()).data.user?.id || '',
timestamp: new Date().toISOString(),
session_elapsed_seconds: event.sessionElapsedSeconds || null,
event_type: event.eventType,
event_payload: event.payload || {},
canvas_snapshot_url: event.snapshotUrl || null,
tldraw_page_id: event.pageId || null,
tldraw_shape_ids: event.shapeIds || null,
}); });
}
return data || []; set({ pendingCanvasEvents: [] });
} catch (error) { } catch (error) {
console.error('Error loading sessions:', error); console.error('Failed to flush canvas events:', error);
}
},
loadSessions: async (): Promise<TranscriptionSession[]> => {
try {
const user = await supabase.auth.getUser();
if (!user.data.user) return [];
const { data, error } = await supabase
.from('transcription_sessions')
.select('*')
.eq('user_id', user.data.user.id)
.order('started_at', { ascending: false })
.limit(50);
if (error) {
console.error('Failed to load sessions:', error);
return []; return [];
} }
},
// LLM config actions - persist to localStorage only return data || [];
setLLMConfig: (partialConfig: Partial<LLMConfig>) => { } catch (error) {
const current = get().llmConfig; console.error('Error loading sessions:', error);
const updated = { ...current, ...partialConfig }; return [];
saveLLMConfig(updated); }
set({ llmConfig: updated }); },
},
getLLMConfig: (): LLMConfig => { // LLM config actions - persist to localStorage only
return get().llmConfig; setLLMConfig: (partialConfig: Partial<LLMConfig>) => {
}, const current = get().llmConfig;
const updated = { ...current, ...partialConfig };
saveLLMConfig(updated);
set({ llmConfig: updated });
},
// Summary actions getLLMConfig: (): LLMConfig => {
setSummaryText: (text: string | null) => { return get().llmConfig;
set({ summaryText: text }); },
},
setIsGeneratingSummary: (generating: boolean) => { // Summary actions
set({ isGeneratingSummary: generating }); setSummaryText: (text: string | null) => {
}, set({ summaryText: text });
},
setSummaryError: (error: string | null) => { setIsGeneratingSummary: (generating: boolean) => {
set({ summaryError: error }); set({ isGeneratingSummary: generating });
}, },
// Export actions setSummaryError: (error: string | null) => {
exportSession: async (sessionId: string, format: ExportFormat) => { set({ summaryError: error });
set({ isExporting: true, exportError: null }); },
try { // Export actions
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; exportSession: async (sessionId: string, format: ExportFormat) => {
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, { set({ isExporting: true, exportError: null });
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ format }),
});
if (!response.ok) { try {
const errorData = await response.json().catch(() => null); const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`); const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ format }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`);
}
// Get filename from Content-Disposition header or use default
const disposition = response.headers.get('Content-Disposition');
let filename = `transcription-export.${format}`;
if (disposition) {
const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/);
if (match && match[1]) {
filename = match[1].replace(/["'\\]/g, '');
} }
// Get filename from Content-Disposition header or use default
const disposition = response.headers.get('Content-Disposition');
let filename = `transcription-export.${format}`;
if (disposition) {
const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/);
if (match && match[1]) {
filename = match[1].replace(/["'\\]/g, '');
}
}
// Trigger browser download
const blob = await response.blob();
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);
} catch (error) {
console.error('Failed to export session:', error);
set({ exportError: error instanceof Error ? error.message : 'Failed to export session' });
} finally {
set({ isExporting: false });
} }
},
setExportError: (error: string | null) => { // Trigger browser download
set({ exportError: error }); const blob = await response.blob();
}, 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);
} catch (error) {
console.error('Failed to export session:', error);
set({ exportError: error instanceof Error ? error.message : 'Failed to export session' });
} finally {
set({ isExporting: false });
}
},
loadKeywordWatches: async () => { setExportError: (error: string | null) => {
try { set({ exportError: error });
const { _accessToken: token } = get(); },
if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; loadKeywordWatches: async () => {
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { try {
headers: { 'Authorization': `Bearer ${token}` }, const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
headers: { 'Authorization': `Bearer ${session.access_token}` },
});
if (!response.ok) return;
const watches = await response.json();
set({ keywordWatches: watches });
} catch (error) {
console.error('Failed to load keyword watches:', error);
}
},
addKeywordWatch: async (keyword: string) => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
const user = await supabase.auth.getUser();
if (!user.data.user) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({
user_id: user.data.user.id,
keyword: keyword.trim(),
match_type: 'contains',
action: 'alert',
}),
});
if (!response.ok) return;
const newWatch = await response.json();
set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] }));
} catch (error) {
console.error('Failed to add keyword watch:', error);
}
},
deleteKeywordWatch: async (watchId: string) => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${session.access_token}` },
});
set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) }));
} catch (error) {
console.error('Failed to delete keyword watch:', error);
}
},
checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => {
const { keywordWatches, activeSession } = get();
if (keywordWatches.length === 0) return;
const lowerText = text.toLowerCase();
const matches: KeywordMatch[] = [];
for (const watch of keywordWatches) {
if (!watch.is_active) continue;
const lowerKeyword = watch.keyword.toLowerCase();
const matched =
watch.match_type === 'exact'
? lowerText === lowerKeyword
: watch.match_type === 'starts_with'
? lowerText.startsWith(lowerKeyword)
: lowerText.includes(lowerKeyword);
if (matched) {
matches.push({
keyword: watch.keyword,
watch_id: watch.id,
segment_text: text,
elapsed_seconds: elapsedSeconds,
matched_at: new Date().toISOString(),
}); });
if (!response.ok) return;
const watches = await response.json();
set({ keywordWatches: watches });
} catch (error) {
console.error('Failed to load keyword watches:', error);
}
},
addKeywordWatch: async (keyword: string) => { if (activeSession) {
try { try {
const { _accessToken: token, _userId: userId } = get(); const { data: { session } } = await supabase.auth.getSession();
if (!token || !userId) return; const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { method: 'POST',
method: 'POST', headers: {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', ...(session?.access_token ? { 'Authorization': `Bearer ${session.access_token}` } : {}),
'Authorization': `Bearer ${token}`, },
}, body: JSON.stringify({
body: JSON.stringify({ session_id: activeSession.id,
user_id: userId, keyword_watch_id: watch.id,
keyword: keyword.trim(), keyword_text: watch.keyword,
match_type: 'contains', matched_in_text: text,
action: 'alert', session_elapsed_seconds: elapsedSeconds,
}), }),
}); });
if (!response.ok) return; } catch (error) {
const newWatch = await response.json(); console.error('Failed to log keyword event:', error);
set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] }));
} catch (error) {
console.error('Failed to add keyword watch:', error);
}
},
deleteKeywordWatch: async (watchId: string) => {
try {
const { _accessToken: token } = get();
if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) }));
} catch (error) {
console.error('Failed to delete keyword watch:', error);
}
},
checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => {
const { keywordWatches, activeSession } = get();
if (keywordWatches.length === 0) return;
const lowerText = text.toLowerCase();
const matches: KeywordMatch[] = [];
for (const watch of keywordWatches) {
if (!watch.is_active) continue;
const lowerKeyword = watch.keyword.toLowerCase();
const matched =
watch.match_type === 'exact'
? lowerText === lowerKeyword
: watch.match_type === 'starts_with'
? lowerText.startsWith(lowerKeyword)
: lowerText.includes(lowerKeyword);
if (matched) {
matches.push({
keyword: watch.keyword,
watch_id: watch.id,
segment_text: text,
elapsed_seconds: elapsedSeconds,
matched_at: new Date().toISOString(),
});
if (activeSession) {
try {
const { _accessToken: kwToken } = get();
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}),
},
body: JSON.stringify({
session_id: activeSession.id,
keyword_watch_id: watch.id,
keyword_text: watch.keyword,
matched_in_text: text,
session_elapsed_seconds: elapsedSeconds,
}),
});
} catch (error) {
console.error('Failed to log keyword event:', error);
}
} }
} }
} }
}
if (matches.length > 0) { if (matches.length > 0) {
set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] })); set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] }));
} }
}, },
clearKeywordMatches: () => { clearKeywordMatches: () => {
set({ keywordMatches: [] }); set({ keywordMatches: [] });
}, },
}; }));
});

View File

@ -227,13 +227,6 @@ 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>;
@ -246,7 +239,6 @@ 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;

View File

@ -7,14 +7,6 @@ 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;
@ -22,29 +14,26 @@ 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 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;
} }
setServerSegmentsCallback(callback: ServerSegmentsCallback) { setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) {
this.onServerSegments = callback; this.onTranscriptionUpdate = 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 };
@ -69,15 +58,18 @@ export class TranscriptionService {
ws.onopen = () => { ws.onopen = () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
logger.info('transcription-service', '✅ WebSocket connected'); logger.info('transcription-service', '✅ WebSocket connected');
ws.send(JSON.stringify({ // Send initial configuration message
const message = 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 || 'large-v3', model: config.modelSize || 'small',
use_vad: config.useVad ?? true, use_vad: config.useVad ?? true,
max_connection_time: 7200, // server default is 600 s — set to 2 h });
}));
ws.send(message);
this.setupAudioProcessing();
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
@ -86,48 +78,60 @@ 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) return; if (data.uid !== uuid) {
if (data.message === 'SERVER_READY') {
logger.info('transcription-service', '🟢 Server ready, starting audio capture');
this.setupAudioProcessing();
return; return;
} }
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;
} }
// Pass the full segment window directly to the store — the store owns if (this.onTranscriptionUpdate && data.segments) {
// all boundary and archival decisions, matching the WhisperLive reference // Get the last segment which is the current one being updated
// frontend which simply re-renders the server's authoritative segment list. const lastSegment = data.segments[data.segments.length - 1];
if (this.onServerSegments && data.segments && data.segments.length > 0) {
const segs: ServerSegment[] = data.segments.map((s: any) => ({ // Process completed segments
text: String(s.text ?? ''), let lastCompletedText = '';
start: parseFloat(s.start ?? 0), for (let i = 0; i < data.segments.length - 1; i++) {
end: parseFloat(s.end ?? 0), const segment = data.segments[i];
})); // Only send update if this segment is different from the last one
const isLastLive = !(data.segments[data.segments.length - 1]?.completed); if (segment.text.trim() !== lastCompletedText.trim()) {
this.onServerSegments(segs, isLastLive); this.onTranscriptionUpdate(
segment.text,
segment.completed ?? true, // Server marks completed segments
{
start: parseFloat(segment.start),
end: parseFloat(segment.end)
}
);
lastCompletedText = segment.text;
}
}
// Update the current (incomplete) segment only if it's different from the last completed one
if (lastSegment && lastSegment.text.trim() !== lastCompletedText.trim()) {
this.onTranscriptionUpdate(
lastSegment.text,
lastSegment.completed ?? false, // Last segment is typically incomplete unless marked otherwise
{
start: parseFloat(lastSegment.start),
end: parseFloat(lastSegment.end)
}
);
}
} }
}; };
} catch (error) { } catch (error) {
@ -137,18 +141,24 @@ export class TranscriptionService {
} }
private async setupAudioProcessing() { private async setupAudioProcessing() {
if (!this.stream || !this.socket) return; if (!this.stream || !this.socket) {
return;
}
try { try {
this.audioContext = new AudioContext({ sampleRate: 16000 }); this.audioContext = new AudioContext();
// Load and register the audio worklet
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');
// Handle audio data from the worklet
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); const resampledData = this.resampleTo16kHZ(event.data, this.audioContext!.sampleRate);
this.socket.send(resampledData);
} }
}; };
@ -159,11 +169,23 @@ export class TranscriptionService {
} }
} }
stopTranscription() { private resampleTo16kHZ(audioData: Float32Array, origSampleRate: number): Float32Array {
this.intentionalStop = true; const ratio = origSampleRate / 16000;
if (this.socket?.readyState === WebSocket.OPEN) { const newLength = Math.round(audioData.length / ratio);
this.socket.send('END_OF_AUDIO'); const result = new Float32Array(newLength);
for (let i = 0; i < newLength; i++) {
const pos = i * ratio;
const leftPos = Math.floor(pos);
const rightPos = Math.ceil(pos);
const weight = pos - leftPos;
result[i] = audioData[leftPos] * (1 - weight) + (audioData[rightPos] || 0) * weight;
} }
return result;
}
stopTranscription() {
this.cleanup(); this.cleanup();
} }
@ -172,18 +194,22 @@ export class TranscriptionService {
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;

View File

@ -34,7 +34,6 @@ 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';
@ -146,6 +145,7 @@ 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,8 +281,6 @@ 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;
} }
@ -388,11 +386,9 @@ 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>
)} )}
</> </>

View File

@ -1,18 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { 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 { useAuth } from '../../../../../contexts/AuthContext'; import { supabase } from '../../../../../supabaseClient';
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 } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
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);
@ -29,29 +28,27 @@ 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 = useCallback(async (url: string, init?: RequestInitLite) => { const apiFetch = 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 ${accessToken || ''}`, 'Authorization': `Bearer ${bearer}`,
...(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 = useCallback(async () => { const loadCabinets = 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(() => { useEffect(() => { loadCabinets(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, []);
if (authUser?.id) {
loadCabinets();
}
}, [loadCabinets, authUser?.id]);
const handleCreate = async () => { const handleCreate = async () => {
if (!newName.trim()) return; if (!newName.trim()) return;

View File

@ -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 { useAuth } from '../../../../../contexts/AuthContext'; import { supabase } from '../../../../../supabaseClient';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
calculateDirectoryStats, calculateDirectoryStats,
@ -92,8 +92,7 @@ interface FileListResponse {
} }
export const CCFilesPanel: React.FC = () => { export const CCFilesPanel: React.FC = () => {
const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
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>('');
@ -116,7 +115,6 @@ 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[]>([]);
@ -145,14 +143,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 ${accessToken || ''}`, 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`,
...(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();
}, [accessToken, API_BASE]); }, [authToken, API_BASE]);
const loadCabinets = useCallback(async () => { const loadCabinets = useCallback(async () => {
setLoading(true); setLoading(true);
@ -160,16 +158,13 @@ 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 && !initialSelectionDone.current) { if (all.length && !selectedCabinet) setSelectedCabinet(all[0].id);
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);
} }
}, [apiFetch]); }, [selectedCabinet, 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;
@ -208,11 +203,8 @@ export const CCFilesPanel: React.FC = () => {
}, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]); }, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]);
useEffect(() => { useEffect(() => {
if (authUser?.id) { loadCabinets();
initialSelectionDone.current = false; }, [loadCabinets]);
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(() => {

View File

@ -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 { useAuth } from '../../../../../contexts/AuthContext'; import { supabase } from '../../../../../supabaseClient';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
pickDirectory, pickDirectory,
@ -75,8 +75,7 @@ interface UploadProgress {
} }
export const CCFilesPanelEnhanced: React.FC = () => { export const CCFilesPanelEnhanced: React.FC = () => {
const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
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>('');
@ -110,7 +109,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 ${accessToken || ''}`, 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`,
...(init?.headers || {}) ...(init?.headers || {})
}; };
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
@ -141,7 +140,7 @@ export const CCFilesPanelEnhanced: React.FC = () => {
} }
}; };
useEffect(() => { if (authUser?.id) { loadCabinets(); } }, [authUser?.id]); useEffect(() => { loadCabinets(); }, []);
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>) => {

View File

@ -1,7 +1,6 @@
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, ServerSegment } from "../../../../../stores/transcriptionStore"; import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch } 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";
@ -18,26 +17,6 @@ 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 = [
@ -52,7 +31,6 @@ export const CCTranscriptionPanel: React.FC = () => {
const { const {
isRecording, isRecording,
completedSegments, completedSegments,
serverWindow,
currentSegment, currentSegment,
wordCount, wordCount,
elapsedSeconds, elapsedSeconds,
@ -60,14 +38,13 @@ export const CCTranscriptionPanel: React.FC = () => {
timetableContext, timetableContext,
startSession, startSession,
stopSession, stopSession,
updateServerWindow, saveSegment,
resetSession, resetSession,
tickElapsed, tickElapsed,
addCanvasEvent, addCanvasEvent,
flushCanvasEvents, flushCanvasEvents,
loadSessions, loadSessions,
setTimetableContext, setTimetableContext,
setAuthInfo,
llmConfig, llmConfig,
summaryText, summaryText,
isGeneratingSummary, isGeneratingSummary,
@ -85,7 +62,6 @@ 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);
@ -94,7 +70,6 @@ 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');
@ -102,25 +77,17 @@ export const CCTranscriptionPanel: React.FC = () => {
const [newKeyword, setNewKeyword] = useState(''); const [newKeyword, setNewKeyword] = useState('');
const [isAddingKeyword, setIsAddingKeyword] = useState(false); const [isAddingKeyword, setIsAddingKeyword] = useState(false);
// Sync access token into Zustand store so all store actions can use it without getSession() // Load sessions and keyword watches on mount
useEffect(() => { useEffect(() => {
setAuthInfo(accessToken, authUser?.id ?? null); loadSessions().then(setSessions);
}, [accessToken, authUser?.id, setAuthInfo]); loadKeywordWatches();
}, []);
// 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 apiBase = import.meta.env.VITE_API_URL || 'https://api.classroomcopilot.ai'; const response = await fetch('http://192.168.0.64:8000/database/timetables/current-period');
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);
@ -160,16 +127,14 @@ export const CCTranscriptionPanel: React.FC = () => {
try { try {
await startSession(timetableContext || undefined); await startSession(timetableContext || undefined);
const service = new TranscriptionService(); const service = new TranscriptionService();
service.setServerSegmentsCallback((segs: ServerSegment[], isLastLive: boolean) => { service.setTranscriptionCallback((text, isFinal, metadata) => {
updateServerWindow(segs, isLastLive); saveSegment(text, isFinal, metadata);
if (isFinal) {
const { elapsedSeconds: elapsed } = useTranscriptionStore.getState();
checkSegmentForKeywords(text, elapsed);
}
}); });
service.setDisconnectCallback(() => { await service.startTranscription();
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
@ -211,17 +176,16 @@ export const CCTranscriptionPanel: React.FC = () => {
setSessions(loaded); setSessions(loaded);
}; };
// Generate summary — calls LLM providers directly, no backend proxy needed // Generate summary handler
const handleGenerateSummary = async () => { const handleGenerateSummary = async () => {
const config = useTranscriptionStore.getState().llmConfig; if (!activeSession) {
const allSegs = completedSegments; setSummaryError("No active session to generate summary for.");
if (allSegs.length === 0) {
setSummaryError("No transcription segments to summarise yet.");
return; return;
} }
if (!config.model) {
setSummaryError("Please configure an LLM model in Settings first."); const config = useTranscriptionStore.getState().llmConfig;
if (!config.apiKey) {
setSummaryError("Please configure your API key in Settings first.");
return; return;
} }
@ -229,79 +193,30 @@ export const CCTranscriptionPanel: React.FC = () => {
setSummaryError(null); setSummaryError(null);
setShowSummaryModal(false); setShowSummaryModal(false);
const transcript = allSegs.map(s => s.text.trim()).filter(Boolean).join(' ');
const promptMap: Record<string, string> = {
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}`,
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 35 most significant moments or turning points in the lesson.\n\nTranscript:\n${transcript}`,
segment: `Summarise the following classroom transcript in 23 sentences.\n\nTranscript:\n${transcript}`,
};
const prompt = promptMap[summaryType] || promptMap.full_lesson;
try { try {
let summaryResult = ''; 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 (config.provider === 'ollama') { if (!response.ok) {
const base = (config.baseUrl || 'https://ollama.kevlarai.com').replace(/\/$/, ''); const errorData = await response.json().catch(() => null);
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`);
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}`);
} }
setSummaryText(summaryResult); const data = await response.json();
// 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');
@ -450,7 +365,7 @@ export const CCTranscriptionPanel: React.FC = () => {
fontSize: "14px", fontSize: "14px",
fontWeight: 600, fontWeight: 600,
color: "#fff", color: "#fff",
backgroundColor: isRecording ? "#ef4444" : "#2563eb", backgroundColor: isRecording ? "#ef4444" : "var(--color-text)",
transition: "background-color 200ms ease", transition: "background-color 200ms ease",
}} }}
> >
@ -496,49 +411,38 @@ export const CCTranscriptionPanel: React.FC = () => {
</> </>
)} )}
{/* Export button — available whenever there are completed segments */} {/* Export button */}
{completedSegments.length > 0 && ( {activeSession && (
<> <>
<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 ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''}) Export Session
</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={() => { onClick={async () => {
// Build the segment list from store state — always matches what's displayed. try {
const allSegs = [ const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
...completedSegments, const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/export`, {
...(currentSegment && currentSegment.text.trim() ? [{ ...currentSegment, isFinal: true }] : []), method: 'POST',
]; headers: { 'Content-Type': 'application/json' },
const sessionTag = activeSession?.id.slice(0, 8) ?? 'session'; body: JSON.stringify({ format }),
const filename = `${sessionTag}.${format}`; });
if (!response.ok) throw new Error('Export failed');
if (format === 'srt') { const blob = await response.blob();
const content = allSegs const url = window.URL.createObjectURL(blob);
.filter(s => s.text.trim()) const a = document.createElement('a');
.map((seg, i) => a.href = url;
`${i + 1}\n${formatSrtTime(seg.start)} --> ${formatSrtTime(seg.end)}\n${seg.text.trim()}\n` a.download = `session_${activeSession.id.slice(0,8)}_${format}`;
) document.body.appendChild(a);
.join('\n'); a.click();
downloadBlob(content, filename, 'text/plain'); window.URL.revokeObjectURL(url);
} else if (format === 'txt') { document.body.removeChild(a);
const content = allSegs.map(s => s.text.trim()).filter(Boolean).join('\n'); } catch (error) {
downloadBlob(content, filename, 'text/plain'); console.error('Export failed:', error);
} 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={{
@ -627,129 +531,41 @@ export const CCTranscriptionPanel: React.FC = () => {
{/* Live feed */} {/* Live feed */}
<div className="panel-section" style={{ gap: "6px" }}> <div className="panel-section" style={{ gap: "6px" }}>
{/* Header row: title + view mode toggle */} <div className="panel-section-title">Live Feed</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div className="panel-section-title" style={{ margin: 0 }}>Live Feed</div> {completedSegments.map((seg, i) => (
<div style={{ display: "flex", gap: "3px" }}> <div
{(["segments", "transcript"] as const).map(mode => ( key={"completed-" + i}
<button style={{
key={mode} padding: "8px 10px",
onClick={() => setViewMode(mode)} backgroundColor: "#fff",
style={{ borderRadius: "4px",
padding: "2px 8px", border: "1px solid var(--color-divider)",
fontSize: "11px", fontSize: "13px",
backgroundColor: viewMode === mode ? "#2563eb" : "var(--color-muted)", color: "var(--color-text)",
color: viewMode === mode ? "#fff" : "var(--color-text-2)", lineHeight: 1.4,
border: "1px solid var(--color-divider)", }}
borderRadius: "3px", >
cursor: "pointer", {seg.text}
textTransform: "capitalize",
}}
>
{mode}
</button>
))}
</div> </div>
</div> ))}
{(() => { {currentSegment && (
const allFinal = completedSegments; // already merged from server on every message <div
style={{
if (viewMode === "segments") { padding: "8px 10px",
return ( backgroundColor: "var(--color-panel)",
<> borderRadius: "4px",
{allFinal.map((seg, i) => ( border: "1px dashed var(--color-divider)",
<div fontSize: "13px",
key={"seg-" + i} color: "var(--color-text-2)",
style={{ fontStyle: "italic",
padding: "7px 10px", lineHeight: 1.4,
backgroundColor: "var(--color-muted)", }}
borderRadius: "6px", >
border: "1px solid var(--color-divider)", {currentSegment.text || "Listening..."}
}} </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" }}>
@ -798,7 +614,7 @@ export const CCTranscriptionPanel: React.FC = () => {
key={session.id} key={session.id}
style={{ style={{
padding: "10px", padding: "10px",
backgroundColor: "var(--color-muted)", backgroundColor: "#fff",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid var(--color-divider)", border: "1px solid var(--color-divider)",
}} }}
@ -878,7 +694,7 @@ export const CCTranscriptionPanel: React.FC = () => {
padding: "6px 10px", padding: "6px 10px",
border: "none", border: "none",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: newKeyword.trim() ? "#2563eb" : "var(--color-divider)", backgroundColor: newKeyword.trim() ? "var(--color-text)" : "var(--color-divider)",
color: "#fff", color: "#fff",
cursor: newKeyword.trim() ? "pointer" : "not-allowed", cursor: newKeyword.trim() ? "pointer" : "not-allowed",
fontSize: "13px", fontSize: "13px",
@ -906,7 +722,7 @@ export const CCTranscriptionPanel: React.FC = () => {
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
padding: "6px 10px", padding: "6px 10px",
backgroundColor: "var(--color-muted)", backgroundColor: "#fff",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid var(--color-divider)", border: "1px solid var(--color-divider)",
}} }}
@ -990,86 +806,67 @@ export const CCTranscriptionPanel: React.FC = () => {
{/* Summary Type Selection Modal */} {/* Summary Type Selection Modal */}
{showSummaryModal && ( {showSummaryModal && (
<div <div className="fixed inset-0 z-[9999] overflow-y-auto">
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
style={{ className="fixed inset-0 bg-black/50 transition-opacity"
position: 'relative', onClick={() => setShowSummaryModal(false)}
width: '100%', />
maxWidth: '360px', <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">
backgroundColor: 'var(--color-panel)', <div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
border: '1px solid var(--color-divider)', <h3 className="text-lg font-medium leading-6 text-gray-900">
borderRadius: '10px', Generate Summary
boxShadow: '0 20px 60px rgba(0,0,0,0.4)', </h3>
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)}
style={{ padding: '2px 6px', border: 'none', backgroundColor: 'transparent', color: 'var(--color-text-2)', cursor: 'pointer', fontSize: '18px', lineHeight: 1 }} className="text-gray-400 hover:text-gray-500 focus:outline-none"
> >
× <Close sx={{ fontSize: 20 }} />
</button> </button>
</div> </div>
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}> <div className="px-4 py-5 sm:p-6 space-y-4">
<div> <div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-2)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}> <label className="block text-sm font-medium text-gray-700 mb-1">
Summary Type Summary Type
</label> </label>
<select <select
value={summaryType} value={summaryType}
onChange={(e) => setSummaryType(e.target.value)} onChange={(e) => setSummaryType(e.target.value)}
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' }} 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"
> >
{SUMMARY_TYPES.map((t) => ( {SUMMARY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option> <option key={t.value} value={t.value}>
{t.label}
</option>
))} ))}
</select> </select>
</div> </div>
{/* Config status indicator */}
<div style={{ <div style={{
padding: '8px 10px', padding: "8px",
borderRadius: '6px', borderRadius: "6px",
fontSize: '12px', fontSize: "12px",
backgroundColor: llmConfig.model ? 'var(--color-muted)' : '#fef2f2', backgroundColor: llmConfig.apiKey ? "#f0fdf4" : "#fef2f2",
color: llmConfig.model ? 'var(--color-text-2)' : '#dc2626', color: llmConfig.apiKey ? "#16a34a" : "#dc2626",
border: '1px solid var(--color-divider)', border: `1px solid ${llmConfig.apiKey ? "#bbf7d0" : "#fecaca"}`,
}}> }}>
{llmConfig.model {llmConfig.apiKey ? (
? <> {llmConfig.provider} · {llmConfig.model}</> <> Configured: {llmConfig.provider} ({llmConfig.model || 'default 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.model} disabled={isGeneratingSummary || !llmConfig.apiKey}
style={{ className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
padding: '9px', isGeneratingSummary || !llmConfig.apiKey
border: 'none', ? 'bg-gray-400 cursor-not-allowed'
borderRadius: '6px', : 'bg-purple-600 hover:bg-purple-700'
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>

View File

@ -1,49 +1,15 @@
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 (local)' }, { value: 'ollama', label: 'Ollama' },
{ value: 'openrouter', label: 'OpenRouter' }, { value: 'openrouter', label: 'OpenRouter' },
{ value: 'google', label: 'Google Gemini' }, { value: 'google', label: 'Google' },
] 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);
@ -59,196 +25,97 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is
const handleSave = () => { const handleSave = () => {
setLLMConfig(form); setLLMConfig(form);
setSaved(true); setSaved(true);
setTimeout(() => { setTimeout(() => setSaved(false), 2000);
setSaved(false);
onClose();
}, 1000);
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div <div className="fixed inset-0 z-[9999] overflow-y-auto">
style={{
position: 'fixed',
inset: 0,
zIndex: 99999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
{/* Backdrop */} {/* Backdrop */}
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} /> <div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
{/* Modal panel */} {/* Modal panel */}
<div <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">
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 style={{ <div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
padding: '14px 16px', <h3 className="text-lg font-medium leading-6 text-gray-900">
borderBottom: '1px solid var(--color-divider)', LLM Provider Settings
display: 'flex', </h3>
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Settings
</span>
<button <button
onClick={onClose} onClick={onClose}
style={{ className="text-gray-400 hover:text-gray-500 focus:outline-none"
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 style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '80vh', overflowY: 'auto' }}> <div className="px-4 py-5 sm:p-6 space-y-4">
{/* Provider dropdown */}
{/* ── Transcription section ── */}
<div> <div>
<div style={{ <label className="block text-sm font-medium text-gray-700 mb-1">
fontSize: '11px', Provider
fontWeight: 700, </label>
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.whisperModel || 'large-v3'} value={form.provider}
onChange={(e) => setForm({ ...form, whisperModel: e.target.value })} onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
style={fieldStyle} 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"
> >
{WHISPER_MODELS.map((m) => ( {PROVIDERS.map((p) => (
<option key={m.value} value={m.value}>{m.label}</option> <option key={p.value} value={p.value}>
{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>
{/* ── LLM section ── */} {/* Model name */}
<div> <div>
<div style={{ <label className="block text-sm font-medium text-gray-700 mb-1">
fontSize: '11px', Model
fontWeight: 700, </label>
color: 'var(--color-text-3)', <input
textTransform: 'uppercase', type="text"
letterSpacing: '0.08em', value={form.model}
marginBottom: '10px', onChange={(e) => setForm({ ...form, model: e.target.value })}
paddingBottom: '6px', placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
borderBottom: '1px solid var(--color-divider)', 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"
}}> />
AI Summary Provider
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={labelStyle}>Provider</label>
<select
value={form.provider}
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
style={fieldStyle}
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Model</label>
<input
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> </div>
{/* API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<input
type="password"
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder="sk-..."
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"
/>
</div>
{/* Note */}
<p className="text-xs text-gray-500">
API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server.
</p>
{/* Save button */} {/* Save button */}
<button <button
onClick={handleSave} onClick={handleSave}
style={{ className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
padding: '9px', saved
border: 'none', ? 'bg-green-600 hover:bg-green-700'
borderRadius: '6px', : 'bg-blue-600 hover:bg-blue-700'
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>

View File

@ -1,586 +0,0 @@
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>
);
}

View File

@ -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 { useAuth } from '../../../../../../contexts/AuthContext'; import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService';
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,7 +68,6 @@ 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);
@ -132,9 +131,8 @@ export const CCNodeSnapshotPanel: React.FC = () => {
type: navigationContext.node.type type: navigationContext.node.type
}); });
const storagePath = navigationContext.node.node_storage_path; const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node);
if (!storagePath) throw new Error('No storage path on current node'); await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store);
await NavigationSnapshotService.saveNodeSnapshotToDatabase(storagePath, accessToken || '', editor.store);
addToast({ addToast({
title: 'Snapshot saved', title: 'Snapshot saved',

View File

@ -1,316 +0,0 @@
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>
);
}

View File

@ -1,244 +0,0 @@
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>
);
}