feat(phase-b): Supabase navigation store, snapshot service, auth wiring

navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-26 01:25:15 +01:00
parent 3a65cf436b
commit b0c7758135
11 changed files with 1641 additions and 1892 deletions

View File

@ -1,333 +1,60 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState } from 'react';
import { import {
IconButton, IconButton, Tooltip, Box, Menu, MenuItem,
Tooltip, ListItemIcon, ListItemText, Chip, styled,
Box,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Button,
styled
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack as ArrowBackIcon, ArrowBack as ArrowBackIcon,
ArrowForward as ArrowForwardIcon, ArrowForward as ArrowForwardIcon,
History as HistoryIcon, History as HistoryIcon,
School as SchoolIcon, Home as HomeIcon,
Person as PersonIcon, CalendarToday,
AccountCircle as AccountCircleIcon, DateRange,
CalendarToday as CalendarIcon, Event,
School as TeachingIcon, WorkspacesOutlined,
Business as BusinessIcon,
AccountTree as DepartmentIcon,
Class as ClassIcon,
ExpandMore as ExpandMoreIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigationStore } from '../../stores/navigationStore'; import { useNavigationStore } from '../../stores/navigationStore';
import { useNeoUser } from '../../contexts/NeoUserContext';
import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts';
import {
BaseContext,
ViewContext
} from '../../types/navigation';
import { logger } from '../../debugConfig';
const NavigationRoot = styled(Box)` const NavigationRoot = styled(Box)`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
height: 100%; height: 100%;
overflow: hidden;
`; `;
const NavigationControls = styled(Box)` function getNodeIcon(nodeType: string) {
display: flex; switch (nodeType) {
align-items: center; case 'User': return <HomeIcon fontSize="small" />;
gap: 4px; case 'CalendarYear': return <CalendarToday fontSize="small" />;
`; case 'CalendarMonth': return <DateRange fontSize="small" />;
case 'CalendarDay': return <Event fontSize="small" />;
const ContextToggleContainer = styled(Box)(({ theme }) => ({ default: return <WorkspacesOutlined fontSize="small" />;
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0.5),
gap: theme.spacing(0.5),
'& .button-label': {
'@media (max-width: 500px)': {
display: 'none'
} }
} }
}));
const ContextToggleButton = styled(Button, {
shouldForwardProp: (prop) => prop !== 'active'
})<{ active?: boolean }>(({ theme, active }) => ({
minWidth: 0,
padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius,
backgroundColor: active ? theme.palette.primary.main : 'transparent',
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
textTransform: 'none',
transition: theme.transitions.create(['background-color', 'color'], {
duration: theme.transitions.duration.shorter,
}),
'&:hover': {
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
},
'@media (max-width: 500px)': {
padding: theme.spacing(0.5),
}
}));
export const GraphNavigator: React.FC = () => { export const GraphNavigator: React.FC = () => {
const { const { context, goBack, goForward, isLoading } = useNavigationStore();
context,
switchContext,
goBack,
goForward,
isLoading
} = useNavigationStore();
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null); const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [availableWidth, setAvailableWidth] = useState<number>(0);
useEffect(() => {
const calculateAvailableSpace = () => {
if (!rootRef.current) return;
// Get the header element
const header = rootRef.current.closest('.MuiToolbar-root');
if (!header) return;
// Get the title and menu elements
const title = header.querySelector('.app-title');
const menu = header.querySelector('.menu-button');
if (!title || !menu) return;
// Calculate available width
const headerWidth = header.clientWidth;
const titleWidth = title.clientWidth;
const menuWidth = menu.clientWidth;
const padding = 48; // Increased buffer space
const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding;
console.log('Available width:', newAvailableWidth); // Debug log
setAvailableWidth(newAvailableWidth);
};
// Set up ResizeObserver
const resizeObserver = new ResizeObserver(() => {
// Use requestAnimationFrame to debounce calculations
window.requestAnimationFrame(calculateAvailableSpace);
});
// Observe both the root element and the header
if (rootRef.current) {
const header = rootRef.current.closest('.MuiToolbar-root');
if (header) {
resizeObserver.observe(header);
resizeObserver.observe(rootRef.current);
}
}
// Initial calculation
calculateAvailableSpace();
return () => {
resizeObserver.disconnect();
};
}, []);
// Helper function to determine what should be visible
const getVisibility = () => {
// Adjusted thresholds and collapse order:
// 1. Navigation controls (back/forward/history) collapse first
// 2. Toggle labels collapse second
// 3. Context label collapses last
if (availableWidth < 300) {
return {
navigation: false,
contextLabel: true, // Keep context label visible longer
toggleLabels: false
};
} else if (availableWidth < 450) {
return {
navigation: false,
contextLabel: true, // Keep context label visible
toggleLabels: true
};
} else if (availableWidth < 600) {
return {
navigation: true,
contextLabel: true,
toggleLabels: true
};
}
return {
navigation: true,
contextLabel: true,
toggleLabels: true
};
};
const visibility = getVisibility();
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
setHistoryMenuAnchor(event.currentTarget);
};
const handleHistoryClose = () => {
setHistoryMenuAnchor(null);
};
const handleHistoryItemClick = (index: number) => {
const {currentIndex} = context.history;
const steps = index - currentIndex;
if (steps < 0) {
for (let i = 0; i < -steps; i++) {
goBack();
}
} else if (steps > 0) {
for (let i = 0; i < steps; i++) {
goForward();
}
}
handleHistoryClose();
};
const handleContextChange = useCallback(async (newContext: BaseContext) => {
try {
// Check if trying to access institute contexts without worker database
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
return;
}
// Check if trying to access profile contexts without user database
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
return;
}
logger.debug('navigation', '🔄 Changing main context', {
from: context.main,
to: newContext,
userDbName,
workerDbName
});
// Get default view for new context
const defaultView = getDefaultViewForContext(newContext);
// Use unified context switch with both base and extended contexts
await switchContext({
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
base: newContext,
extended: defaultView,
skipBaseContextLoad: false
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to change context:', error);
}
}, [context.main, switchContext, userDbName, workerDbName]);
// Helper function to get default view for a context
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
switch (context) {
case 'calendar':
return 'overview';
case 'teaching':
return 'overview';
case 'school':
return 'overview';
case 'department':
return 'overview';
case 'class':
return 'overview';
default:
return 'overview';
}
};
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
setContextMenuAnchor(event.currentTarget);
};
const handleContextSelect = useCallback(async (context: BaseContext) => {
setContextMenuAnchor(null);
try {
// Use unified context switch with both base and extended contexts
const contextDef = NAVIGATION_CONTEXTS[context];
const defaultExtended = contextDef?.views[0]?.id;
await switchContext({
base: context,
extended: defaultExtended
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to select context:', error);
}
}, [switchContext, userDbName, workerDbName]);
const getContextItems = useCallback(() => {
if (context.main === 'profile') {
return [
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
];
} else {
return [
{ id: 'school', label: 'School', icon: BusinessIcon },
{ id: 'department', label: 'Department', icon: DepartmentIcon },
{ id: 'class', label: 'Class', icon: ClassIcon },
];
}
}, [context.main]);
const getContextIcon = useCallback((contextType: string) => {
switch (contextType) {
case 'profile':
return <AccountCircleIcon />;
case 'calendar':
return <CalendarIcon />;
case 'teaching':
return <TeachingIcon />;
case 'school':
return <BusinessIcon />;
case 'department':
return <DepartmentIcon />;
case 'class':
return <ClassIcon />;
default:
return <AccountCircleIcon />;
}
}, []);
const isDisabled = !isNeoUserInitialized || isLoading;
const { history } = context; const { history } = context;
const canGoBack = history.currentIndex > 0; const canGoBack = history.currentIndex > 0;
const canGoForward = history.currentIndex < history.nodes.length - 1; const canGoForward = history.currentIndex < history.nodes.length - 1;
const currentNode = context.node;
const handleHistoryClick = (e: React.MouseEvent<HTMLElement>) => setHistoryMenuAnchor(e.currentTarget);
const handleHistoryClose = () => setHistoryMenuAnchor(null);
const handleHistoryItemClick = (index: number) => {
const delta = index - history.currentIndex;
if (delta < 0) for (let i = 0; i < -delta; i++) goBack();
else if (delta > 0) for (let i = 0; i < delta; i++) goForward();
handleHistoryClose();
};
return ( return (
<NavigationRoot ref={rootRef}> <NavigationRoot>
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
<Tooltip title="Back"> <Tooltip title="Back">
<span> <span>
<IconButton <IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
onClick={goBack}
disabled={!canGoBack || isDisabled}
size="small"
>
<ArrowBackIcon fontSize="small" /> <ArrowBackIcon fontSize="small" />
</IconButton> </IconButton>
</span> </span>
@ -337,7 +64,7 @@ export const GraphNavigator: React.FC = () => {
<span> <span>
<IconButton <IconButton
onClick={handleHistoryClick} onClick={handleHistoryClick}
disabled={!history.nodes.length || isDisabled} disabled={!history.nodes.length}
size="small" size="small"
> >
<HistoryIcon fontSize="small" /> <HistoryIcon fontSize="small" />
@ -347,112 +74,48 @@ export const GraphNavigator: React.FC = () => {
<Tooltip title="Forward"> <Tooltip title="Forward">
<span> <span>
<IconButton <IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
onClick={goForward}
disabled={!canGoForward || isDisabled}
size="small"
>
<ArrowForwardIcon fontSize="small" /> <ArrowForwardIcon fontSize="small" />
</IconButton> </IconButton>
</span> </span>
</Tooltip> </Tooltip>
</NavigationControls>
{/* History Menu */} {currentNode && (
<Chip
size="small"
icon={getNodeIcon(currentNode.type)}
label={currentNode.label || currentNode.type}
variant="outlined"
sx={{ maxWidth: 200, fontSize: '0.75rem' }}
/>
)}
<Menu <Menu
anchorEl={historyMenuAnchor} anchorEl={historyMenuAnchor}
open={Boolean(historyMenuAnchor)} open={Boolean(historyMenuAnchor)}
onClose={handleHistoryClose} onClose={handleHistoryClose}
anchorOrigin={{ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
vertical: 'bottom', transformOrigin={{ vertical: 'top', horizontal: 'center' }}
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
> >
{history.nodes.map((node, index) => ( {history.nodes.map((node, index) => (
<MenuItem <MenuItem
key={`${node.id}-${index}`} key={`${node.id}-${index}`}
onClick={() => handleHistoryItemClick(index)} onClick={() => handleHistoryItemClick(index)}
selected={index === history.currentIndex} selected={index === history.currentIndex}
dense
> >
<ListItemIcon> <ListItemIcon sx={{ minWidth: 32 }}>
{getContextIcon(node.type)} {getNodeIcon(node.type)}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={node.label || node.id} primary={node.label || node.id}
secondary={node.type} secondary={node.type}
primaryTypographyProps={{ fontSize: '0.8rem' }}
secondaryTypographyProps={{ fontSize: '0.7rem' }}
/> />
</MenuItem> </MenuItem>
))} ))}
</Menu> </Menu>
<ContextToggleContainer>
<ContextToggleButton
active={context.main === 'profile'}
onClick={() => handleContextChange('profile' as BaseContext)}
startIcon={<PersonIcon />}
disabled={isDisabled || !userDbName}
>
{visibility.toggleLabels && <span className="button-label">Profile</span>}
</ContextToggleButton>
<ContextToggleButton
active={context.main === 'institute'}
onClick={() => handleContextChange('school' as BaseContext)}
startIcon={<SchoolIcon />}
disabled={isDisabled || !workerDbName}
>
{visibility.toggleLabels && <span className="button-label">Institute</span>}
</ContextToggleButton>
</ContextToggleContainer>
<Box>
<Tooltip title={context.base}>
<span>
<Button
onClick={handleContextMenu}
disabled={isDisabled}
sx={{
minWidth: 0,
p: 0.5,
color: 'text.primary',
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
{getContextIcon(context.base)}
{visibility.contextLabel && (
<Box sx={{ ml: 1 }}>
{context.base}
</Box>
)}
<ExpandMoreIcon sx={{ ml: visibility.contextLabel ? 0.5 : 0 }} />
</Button>
</span>
</Tooltip>
</Box>
<Menu
anchorEl={contextMenuAnchor}
open={Boolean(contextMenuAnchor)}
onClose={() => setContextMenuAnchor(null)}
>
{getContextItems().map(item => (
<MenuItem
key={item.id}
onClick={() => handleContextSelect(item.id as BaseContext)}
disabled={isDisabled}
>
<ListItemIcon>
<item.icon />
</ListItemIcon>
<ListItemText primary={item.label} />
</MenuItem>
))}
</Menu>
</NavigationRoot> </NavigationRoot>
); );
}; };

View File

@ -0,0 +1,225 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, IconButton, CircularProgress, Tooltip, Collapse,
} from '@mui/material';
import {
ChevronLeft, ChevronRight,
ExpandMore, ChevronRight as ChevronRightIcon,
Home as HomeIcon, CalendarToday, DateRange, Event,
} from '@mui/icons-material';
import { useNavigationStore } from '../../stores/navigationStore';
import { useAuth } from '../../contexts/AuthContext';
import { NeoGraphNode } from '../../types/navigation';
import { logger } from '../../debugConfig';
interface TreeNode extends NeoGraphNode {
has_children?: boolean;
children?: TreeNode[];
}
const NODE_ICONS: Record<string, React.ElementType> = {
User: HomeIcon,
CalendarYear: CalendarToday,
CalendarMonth: DateRange,
CalendarWeek: DateRange,
CalendarDay: Event,
};
const SIDEBAR_WIDTH = 220;
interface TreeItemProps {
node: TreeNode;
depth: number;
onSelect: (node: TreeNode) => void;
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
activeRoomId?: string;
}
function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
const [loading, setLoading] = useState(false);
const Icon = NODE_ICONS[node.node_type] || HomeIcon;
const canExpand = node.has_children !== false && node.node_type !== 'CalendarDay';
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!expanded && children.length === 0 && canExpand) {
setLoading(true);
try {
const loaded = await onExpand(node);
setChildren(loaded);
} finally {
setLoading(false);
}
}
setExpanded(v => !v);
};
return (
<Box>
<Box
onClick={() => onSelect(node)}
sx={{
display: 'flex', alignItems: 'center',
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.4,
cursor: 'pointer', borderRadius: 1, mx: 0.5,
fontSize: '0.78rem', minHeight: 28,
bgcolor: 'transparent',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{canExpand && (
loading
? <CircularProgress size={10} />
: (
<IconButton
size="small" sx={{ p: 0, color: 'text.secondary' }}
onClick={handleToggle}
>
{expanded
? <ExpandMore sx={{ fontSize: 14 }} />
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
</IconButton>
)
)}
</Box>
<Icon sx={{ fontSize: 14, mr: 0.75, flexShrink: 0, color: 'text.secondary' }} />
<Box sx={{
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
flexGrow: 1, color: 'text.primary',
}}>
{node.label}
</Box>
</Box>
{canExpand && (
<Collapse in={expanded} timeout="auto">
{children.map(child => (
<TreeItem
key={child.neo4j_node_id}
node={child}
depth={depth + 1}
onSelect={onSelect}
onExpand={onExpand}
activeRoomId={activeRoomId}
/>
))}
</Collapse>
)}
</Box>
);
}
interface GraphSidebarProps {
open: boolean;
onToggle: () => void;
}
export function GraphSidebar({ open, onToggle }: GraphSidebarProps) {
const { accessToken } = useAuth();
const { navigateToNeoNode, context } = useNavigationStore();
const [tree, setTree] = useState<TreeNode | null>(null);
const [loading, setLoading] = useState(false);
const apiBase = import.meta.env.VITE_API_BASE as string;
const fetchTree = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
try {
const res = await fetch(`${apiBase}/graph/tree`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`Graph tree fetch failed: ${res.status}`);
const data = await res.json();
setTree(data.tree);
} catch (err) {
logger.error('graph-sidebar', 'Failed to load graph tree', err);
} finally {
setLoading(false);
}
}, [accessToken, apiBase]);
useEffect(() => {
if (open && !tree && accessToken) fetchTree();
}, [open, tree, accessToken, fetchTree]);
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
if (!accessToken) return [];
const params = new URLSearchParams({
neo4j_node_id: node.neo4j_node_id,
neo4j_db_name: node.neo4j_db_name,
node_type: node.node_type,
});
try {
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return [];
const data = await res.json();
return data.children || [];
} catch {
return [];
}
}, [accessToken, apiBase]);
return (
<Box sx={{ position: 'relative', height: '100%', display: 'flex', flexShrink: 0 }}>
<Box
sx={{
width: open ? SIDEBAR_WIDTH : 0,
overflow: 'hidden',
transition: 'width 0.2s ease',
bgcolor: 'background.paper',
borderRight: 1,
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Box sx={{ overflowY: 'auto', flexGrow: 1, pt: 1 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
<CircularProgress size={20} />
</Box>
) : tree ? (
<TreeItem
node={tree}
depth={0}
onSelect={n => navigateToNeoNode(n)}
onExpand={handleExpand}
activeRoomId={context.node?.id}
/>
) : null}
</Box>
</Box>
<Tooltip title={open ? 'Collapse sidebar' : 'Expand sidebar'} placement="right">
<IconButton
onClick={onToggle}
size="small"
sx={{
position: 'absolute',
right: -14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
width: 22,
height: 44,
borderRadius: '0 4px 4px 0',
'&:hover': { bgcolor: 'action.hover' },
}}
>
{open
? <ChevronLeft sx={{ fontSize: 14 }} />
: <ChevronRight sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
</Box>
);
}

View File

@ -33,9 +33,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
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 [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);
@ -69,18 +71,47 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { user: resolvedUser, role: resolvedRole }; return { user: resolvedUser, role: resolvedRole };
}, []); }, []);
const triggerUserInit = useCallback((token: string) => {
fetch(`${apiBase}/user/init`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.json())
.then(data => logger.debug('auth-context', '✅ User init', data))
.catch(err => logger.warn('auth-context', '⚠️ User init failed', { err }));
}, [apiBase]);
useEffect(() => { useEffect(() => {
// Canonical Supabase auth pattern: rely solely on onAuthStateChange.
// INITIAL_SESSION fires immediately with the current session state,
// eliminating the race condition between loadInitialSession + onAuthStateChange.
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => { async (event, session) => {
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session }); logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
switch (event) { if (event === 'SIGNED_IN') {
case 'INITIAL_SESSION': persistSession(session ?? null);
case 'SIGNED_IN': if (session?.user) {
case 'TOKEN_REFRESHED': { try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser);
setUserRole(role);
setAccessToken(session.access_token ?? null);
triggerUserInit(session.access_token);
} 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 === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
persistSession(session ?? null); persistSession(session ?? null);
if (session?.user) { if (session?.user) {
try { try {
@ -100,26 +131,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUserRole(null); setUserRole(null);
setAccessToken(null); setAccessToken(null);
} }
// Always clear loading after the first auth event resolves
setLoading(false); setLoading(false);
break; return;
} }
case 'SIGNED_OUT': {
if (event === 'SIGNED_OUT') {
persistSession(null); persistSession(null);
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null); setAccessToken(null);
setLoading(false); setLoading(false);
break;
}
default:
break;
} }
} }
); );
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [buildUserFromSupabase, persistSession]); }, [buildUserFromSupabase, persistSession, triggerUserInit]);
const signIn = async (email: string, password: string) => { const signIn = async (email: string, password: string) => {
try { try {

View File

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

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,6 +46,8 @@ interface LoadingState {
export default function SinglePlayerPage() { export default function SinglePlayerPage() {
// Context hooks with initialization states // Context hooks with initialization states
const { profile: user, loading: userLoading } = useUser(); const { profile: user, loading: userLoading } = useUser();
const { accessToken } = useAuth();
const { context, setAuthInfo, switchContext } = useNavigationStore();
const { const {
tldrawPreferences, tldrawPreferences,
initializePreferences, initializePreferences,
@ -55,8 +57,6 @@ export default function SinglePlayerPage() {
const routerNavigate = useNavigate(); const routerNavigate = useNavigate();
const location = useLocation(); const location = useLocation();
// Navigation store
const { context } = useNavigationStore();
// Refs // Refs
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
@ -114,6 +114,7 @@ export default function SinglePlayerPage() {
// 2. Initialize snapshot service // 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined); const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
if (accessToken) snapshotService.setAccessToken(accessToken);
snapshotServiceRef.current = snapshotService; snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService'); logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
@ -131,12 +132,14 @@ export default function SinglePlayerPage() {
await NavigationSnapshotService.loadNodeSnapshotFromDatabase( await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath, nodeStoragePath,
null, accessToken || '',
newStore, newStore,
setLoadingState, setLoadingState,
undefined, // sharedStore undefined,
editorRef.current || undefined // editor editorRef.current || undefined
); );
// Wire auto-save: set the current path on the service instance
snapshotService.setCurrentNodePath(nodeStoragePath);
logger.debug('single-player-page', '✅ Snapshot loaded from database'); logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else { } else {
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', { logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
@ -152,7 +155,7 @@ export default function SinglePlayerPage() {
let isAutoSaving = false; let isAutoSaving = false;
newStore.listen(() => { newStore.listen(() => {
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) { if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) {
// Skip if already saving // Skip if already saving
if (isAutoSaving) { if (isAutoSaving) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving'); logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
@ -178,8 +181,6 @@ export default function SinglePlayerPage() {
isAutoSaving = false; isAutoSaving = false;
} }
}, 2000); // Increased to 2 seconds debounce }, 2000); // Increased to 2 seconds debounce
} else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet');
} }
}); });
@ -254,9 +255,14 @@ export default function SinglePlayerPage() {
try { try {
setLoadingState({ status: 'loading', error: '' }); setLoadingState({ status: 'loading', error: '' });
// Center the node if (context.node.type !== 'workspace') {
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: '' });
@ -297,12 +303,17 @@ export default function SinglePlayerPage() {
? context.history.nodes[context.history.currentIndex - 1] ? context.history.nodes[context.history.currentIndex - 1]
: null; : null;
// Handle navigation in snapshot service // Handle navigation in snapshot service (load/save snapshot)
await snapshotService.handleNavigationStart(previousNode, currentNode); await snapshotService.handleNavigationStart(previousNode, currentNode);
// Center the node on canvas if (currentNode.type !== 'workspace') {
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) {
@ -315,7 +326,17 @@ export default function SinglePlayerPage() {
}; };
handleNodeChange(); handleNodeChange();
}, [context.node, context.history, store, isInitialLoad]); }, [context.node, context.history, store]);
// Inject auth and trigger initial context when token is ready
useEffect(() => {
if (user?.id && accessToken) {
setAuthInfo(accessToken, user.id);
if (!context.node) {
switchContext({ main: 'profile', base: 'profile' }, null, null);
}
}
}, [user?.id, accessToken]);
// Initialize preferences when user is available // Initialize preferences when user is available
useEffect(() => { useEffect(() => {
@ -462,9 +483,6 @@ export default function SinglePlayerPage() {
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
top: `${HEADER_HEIGHT}px`, top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}> }}>
{/* Loading overlay - show when loading or contexts not initialized */} {/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && ( {(loadingState.status === 'loading' || !store) && (
@ -527,6 +545,7 @@ export default function SinglePlayerPage() {
// Update snapshot service with editor reference // Update snapshot service with editor reference
if (snapshotServiceRef.current) { if (snapshotServiceRef.current) {
snapshotServiceRef.current.setEditor(editor); snapshotServiceRef.current.setEditor(editor);
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
} }
setIsEditorReady(true); setIsEditorReady(true);
@ -565,68 +584,21 @@ const getNodeStoragePath = (node: NavigationNode): string | null => {
}; };
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => { const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// Validate the node parameter if (!node?.id) throw new Error('Node parameter is required');
if (!node) {
throw new Error('Node parameter is required');
}
if (!node.id) {
throw new Error('Node must have an ID');
}
const nodeStoragePath = getNodeStoragePath(node); const nodeStoragePath = getNodeStoragePath(node);
if (!nodeStoragePath) { if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`);
throw new Error(`Node ${node.id} is missing node_storage_path`);
}
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: node.id,
nodeType: node.type,
nodeLabel: node.label,
nodeStoragePath: nodeStoragePath
});
try {
// 1. Always fetch fresh data
// Create a temporary node object with the correct structure for the service
const normalizedNode = {
...node,
node_storage_path: nodeStoragePath
};
const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
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); const theme = getThemeFromLabel(node.type);
return { return {
...fetchedData.node_data, title: node.label || node.type || '',
title: String(fetchedData.node_data.title || node.label || ''),
w: 500, w: 500,
h: 350, h: 350,
state: { state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null },
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor, headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor, backgroundColor: theme.backgroundColor,
isLocked: false, isLocked: false,
__primarylabel__: node.type, __primarylabel__: node.type,
uuid_string: node.id, uuid_string: node.id,
node_storage_path: nodeStoragePath 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

@ -1,27 +1,52 @@
// External imports // External imports
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw'; import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import logger from '../../debugConfig'; import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService'; import { SharedStoreService } from './sharedStoreService';
import { StorageKeys, storageService } from '../auth/localStorageService';
import { NavigationNode } from '../../types/navigation';
export interface LoadingState { export interface LoadingState {
status: 'loading' | 'ready' | 'error'; status: 'loading' | 'ready' | 'error';
error: string; error: string;
} }
const EMPTY_NODE: NavigationNode = { const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
id: '', const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
node_storage_path: '', const BUCKET = 'cc.users';
type: '',
label: '' async function storageGet(path: string, accessToken: string): Promise<unknown | null> {
}; const url = `${SUPABASE_URL}/storage/v1/object/authenticated/${BUCKET}/${path}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
apikey: SUPABASE_ANON_KEY,
},
});
if (res.status === 404 || res.status === 400) return null;
if (!res.ok) throw new Error(`Storage GET ${res.status}: ${await res.text()}`);
return res.json();
}
async function storagePut(path: string, accessToken: string, data: unknown): Promise<void> {
const url = `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${path}`;
const headers = {
Authorization: `Bearer ${accessToken}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
};
const body = JSON.stringify(data);
// PUT replaces an existing object; POST creates a new one.
// Avoids x-upsert custom header which self-hosted Supabase CORS may block.
let res = await fetch(url, { method: 'PUT', headers, body });
if (!res.ok && (res.status === 404 || res.status === 400)) {
res = await fetch(url, { method: 'POST', headers, body });
}
if (!res.ok) throw new Error(`Storage ${res.status}: ${await res.text()}`);
}
export class NavigationSnapshotService { export class NavigationSnapshotService {
private store: TLStore; private store: TLStore;
private editor: Editor | null = null; private editor: Editor | null = null;
private currentNodePath: string | null = null; private currentNodePath: string | null = null;
private _accessToken: string | null = null;
private isAutoSaveEnabled = true; private isAutoSaveEnabled = true;
private isSaving = false; private isSaving = false;
private isLoading = false; private isLoading = false;
@ -33,24 +58,21 @@ export class NavigationSnapshotService {
this.editor = editor || null; this.editor = editor || null;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', { logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id, storeId: store.id,
hasEditor: !!editor hasEditor: !!editor,
}); });
} }
setEditor(editor: Editor): void { setEditor(editor: Editor): void {
this.editor = editor; this.editor = editor;
logger.debug('snapshot-service', '🔄 Editor reference updated', {
editorId: editor.store.id
});
} }
private static replaceBackslashes(input: string | undefined): string { setAccessToken(token: string): void {
return input ? input.replace(/\\/g, '/') : ''; this._accessToken = token;
} }
static async loadNodeSnapshotFromDatabase( static async loadNodeSnapshotFromDatabase(
nodePath: string, nodePath: string,
dbName: string, accessToken: string,
store: TLStore, store: TLStore,
setLoadingState: (state: LoadingState) => void, setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService, sharedStore?: SharedStoreService,
@ -58,252 +80,102 @@ export class NavigationSnapshotService {
): Promise<void> { ): Promise<void> {
try { try {
setLoadingState({ status: 'loading', error: '' }); setLoadingState({ status: 'loading', error: '' });
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
logger.info('snapshot-service', '📂 Loading file from path', { const snapshot = await storageGet(nodePath, accessToken);
path: nodePath,
db_name: dbName
});
const response = await axios.get( if (!snapshot) {
'/database/tldraw_supabase/get_tldraw_node_file', { logger.debug('snapshot-service', ' No snapshot found at path — clearing canvas', { nodePath });
params: { // Clear all shapes so the canvas is blank for this new node
path: this.replaceBackslashes(nodePath), if (editor) {
db_name: dbName const shapeIds = [...editor.getCurrentPageShapeIds()];
if (shapeIds.length > 0) {
editor.deleteShapes(shapeIds);
} }
} }
); setLoadingState({ status: 'ready', error: '' });
return;
}
const snapshot = response.data; const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown };
logger.debug('snapshot-service', '🔍 Snapshot data received', { if (!snap.document || !snap.session) {
hasSnapshot: !!snapshot, logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath });
hasDocument: !!snapshot?.document, setLoadingState({ status: 'ready', error: '' });
hasSession: !!snapshot?.session, return;
hasSchemaVersion: !!snapshot?.schemaVersion, }
schemaVersion: snapshot?.schemaVersion,
snapshotKeys: snapshot ? Object.keys(snapshot) : []
});
if (snapshot && snapshot.document && snapshot.session) {
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
if (sharedStore) { if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState); await sharedStore.loadSnapshot(snapshot, setLoadingState);
} else { return;
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 = { const snapshotCopy = {
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion, schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
document: snapshot.document, document: snap.document,
session: snapshot.session session: snap.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 { 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) { if (editor) {
loadSnapshot(editor.store, snapshotCopy); 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'); 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 { } else {
// Fallback: use global loadSnapshot if no editor available logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
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: '' }); 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) { } catch (error) {
logger.error('snapshot-service', '❌ Failed to fetch snapshot', { logger.error('snapshot-service', '❌ Failed to load snapshot', {
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error',
}); });
setLoadingState({ setLoadingState({
status: 'error', status: 'error',
error: error instanceof Error ? error.message : 'Failed to load file' error: error instanceof Error ? error.message : 'Failed to load snapshot',
}); });
} }
} }
static async saveNodeSnapshotToDatabase( static async saveNodeSnapshotToDatabase(
nodePath: string, nodePath: string,
dbName: string, accessToken: string,
store: TLStore store: TLStore
): Promise<void> { ): Promise<void> {
try { try {
logger.info('snapshot-service', '💾 Saving snapshot to database', { logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
path: nodePath,
db_name: dbName
});
const snapshot = getSnapshot(store); const snapshot = getSnapshot(store);
await storagePut(nodePath, accessToken, snapshot);
// Debug: Log what we're saving
logger.debug('snapshot-service', '🔍 Snapshot 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'); logger.debug('snapshot-service', '✅ Snapshot saved successfully');
} else {
throw new Error('Failed to save snapshot');
}
} catch (error) { } catch (error) {
logger.error('snapshot-service', '❌ Failed to save snapshot', { logger.error('snapshot-service', '❌ Failed to save snapshot', {
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error',
}); });
throw error; throw error;
} }
} }
private async saveCurrentSnapshot(nodePath: string): Promise<void> { private async saveCurrentSnapshot(nodePath: string): Promise<void> {
if (!this.currentNodePath || this.currentNodePath !== nodePath) { if (!this.currentNodePath || this.currentNodePath !== nodePath) return;
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', { if (!this._accessToken) {
currentPath: this.currentNodePath, logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped');
savePath: nodePath
});
return; return;
} }
try { try {
this.isSaving = true; this.isSaving = true;
const user = storageService.get(StorageKeys.USER); await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store);
if (!user) { logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath });
throw new Error('No user found');
}
const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
if (!dbName) {
logger.debug('snapshot-service', '⚠️ No db name - snapshot save skipped (Phase B will migrate to Supabase Storage)');
return;
}
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 {
@ -311,141 +183,77 @@ export class NavigationSnapshotService {
} }
} }
private async loadSnapshotForNode(node: NavigationNode): Promise<void> { private async loadSnapshotForNode(node: { node_storage_path: string }): Promise<void> {
try { if (!this._accessToken) {
this.isLoading = true; logger.debug('snapshot-service', '⚠️ No access token — snapshot load skipped');
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
if (!dbName) {
logger.debug('snapshot-service', '⚠️ No db name - snapshot load skipped (Phase B will migrate to Supabase Storage)');
return; return;
} }
try {
logger.debug('snapshot-service', '📥 Loading snapshot', { this.isLoading = true;
nodePath: node.node_storage_path,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase( await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.node_storage_path, node.node_storage_path,
dbName, this._accessToken,
this.store, this.store,
(state: LoadingState) => { (state: LoadingState) => {
if (state.status === 'ready') { if (state.status === 'ready') {
this.currentNodePath = node.node_storage_path; this.currentNodePath = node.node_storage_path;
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
nodePath: node.node_storage_path,
currentNodePath: this.currentNodePath
});
} else if (state.status === 'error') {
logger.error('snapshot-service', '❌ Error in load callback', {
error: state.error,
nodePath: node.node_storage_path
});
} }
}, },
undefined, // sharedStore undefined,
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot this.editor || undefined
); );
} catch (error) {
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath: node.node_storage_path
});
throw error;
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
} }
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> { async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> {
if (!toNode) { if (!toNode) return;
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node'); if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
return;
}
// Clear any pending debounce
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Debounce the navigation operation
return new Promise((resolve) => { return new Promise((resolve) => {
this.debounceTimeout = setTimeout(async () => { this.debounceTimeout = setTimeout(async () => {
try { try {
await this.executeNavigation(fromNode || EMPTY_NODE, toNode); await this.executeNavigation(fromNode, toNode);
resolve(); resolve();
} catch (error) { } catch (error) {
logger.error('snapshot-service', '❌ Navigation failed', error); logger.error('snapshot-service', '❌ Navigation failed', error);
throw error; throw error;
} }
}, 100); // 100ms debounce }, 100);
}); });
} }
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> { private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> {
try {
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
from: fromNode.node_storage_path,
to: toNode.node_storage_path,
currentPath: this.currentNodePath
});
// If we're already in a navigation operation, queue this one
if (this.isSaving || this.isLoading) { if (this.isSaving || this.isLoading) {
this.pendingOperation = { this.pendingOperation = {
save: fromNode.node_storage_path || undefined, save: fromNode?.node_storage_path,
load: toNode.node_storage_path load: toNode.node_storage_path,
}; };
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
return; return;
} }
// Clear the store before loading new snapshot
logger.debug('snapshot-service', '🔄 Clearing store');
this.currentNodePath = null; this.currentNodePath = null;
logger.debug('snapshot-service', '🧹 Cleared current node path');
// Load the new node's snapshot
if (toNode.node_storage_path) { if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode); await this.loadSnapshotForNode(toNode);
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
nodePath: toNode.node_storage_path
});
} }
// Process any pending operations
if (this.pendingOperation) { if (this.pendingOperation) {
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation); const op = this.pendingOperation;
const operation = this.pendingOperation;
this.pendingOperation = null; this.pendingOperation = null;
await this.handleNavigationStart( await this.handleNavigationStart(
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null, op.save ? { node_storage_path: op.save } : null,
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null op.load ? { node_storage_path: op.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 {
@ -455,14 +263,11 @@ export class NavigationSnapshotService {
async forceSaveCurrentNode(): Promise<void> { async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) { if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath); await this.saveCurrentSnapshot(this.currentNodePath);
} else {
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
} }
} }
clearCurrentNode(): void { clearCurrentNode(): void {
this.currentNodePath = null; this.currentNodePath = null;
this.store.clear(); this.store.clear();
logger.debug('snapshot-service', '🧹 Cleared current node and store');
} }
} }

View File

@ -1,32 +1,45 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { UserNeoDBService } from '../services/graph/userNeoDBService';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types'; import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { import {
NavigationStore, NavigationStore,
NavigationNode, NavigationNode,
NeoGraphNode,
MainContext, MainContext,
BaseContext, BaseContext,
NavigationContextState, NavigationContextState,
isProfileContext, isProfileContext,
isInstituteContext, isInstituteContext,
getContextDatabase,
addToHistory, addToHistory,
navigateHistory, navigateHistory,
getCurrentHistoryNode, getCurrentHistoryNode,
ExtendedContext, ExtendedContext,
UnifiedContextSwitch, UnifiedContextSwitch,
NodeContext
} from '../types/navigation'; } from '../types/navigation';
interface WhiteboardRoom {
id: string;
user_id: string;
name: string;
context_type: string;
is_default: boolean;
storage_path: string | null;
neo4j_node_id: string | null;
neo4j_db_name: string | null;
node_type: string | null;
}
interface NavigationStoreWithAuth extends NavigationStore {
_accessToken: string | null;
_userId: string | null;
setAuthInfo: (token: string | null, userId: string | null) => void;
}
const initialState: NavigationContextState = { const initialState: NavigationContextState = {
main: 'profile', main: 'profile',
base: 'profile', base: 'profile',
node: null, node: null,
history: { history: { nodes: [], currentIndex: -1 }
nodes: [],
currentIndex: -1
}
}; };
function getDefaultBaseForMain(main: MainContext): BaseContext { function getDefaultBaseForMain(main: MainContext): BaseContext {
@ -38,210 +51,140 @@ function validateContextTransition(
updates: Partial<NavigationContextState> updates: Partial<NavigationContextState>
): NavigationContextState { ): NavigationContextState {
const newState = { ...current, ...updates }; const newState = { ...current, ...updates };
// Validate main context
if (updates.main) { if (updates.main) {
newState.base = getDefaultBaseForMain(updates.main); newState.base = getDefaultBaseForMain(updates.main);
} }
// Validate base context
if (updates.base) { if (updates.base) {
// Ensure base context matches main context
const isValid = newState.main === 'profile' const isValid = newState.main === 'profile'
? isProfileContext(updates.base) ? isProfileContext(updates.base)
: isInstituteContext(updates.base); : isInstituteContext(updates.base);
if (!isValid) { if (!isValid) {
newState.base = getDefaultBaseForMain(newState.main); newState.base = getDefaultBaseForMain(newState.main);
} }
} }
return newState; return newState;
} }
export interface NavigationActions { export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
// Context Navigation const pgFetch = async <T = unknown>(
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>; method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>; table: string,
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>; options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
): Promise<T | null> => {
const token = get()._accessToken;
if (!token) throw new Error('pgFetch: no access token');
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`;
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
};
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>;
};
// Node Navigation const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
navigate: (nodeId: string, dbName: string) => Promise<void>; const userId = get()._userId;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>; if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
// History Navigation const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
goBack: () => void; query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`,
goForward: () => void; });
// Utility Methods if (rooms && rooms.length > 0) {
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>; const room = rooms[0];
} return {
id: room.id,
node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`,
label: room.name,
type: 'workspace',
};
}
export interface NavigationState { const storagePath = `${userId}/workspaces/${contextType}_default.json`;
context: { const room = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
main: NodeContext; body: {
base: NodeContext; user_id: userId,
extended?: string; name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`,
node: NavigationNode; context_type: contextType,
history: { is_default: true,
nodes: NavigationNode[]; storage_path: storagePath,
currentIndex: number; },
prefer: 'return=representation',
single: true,
});
if (!room) throw new Error('Failed to create default whiteboard room');
logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id });
return {
id: room.id,
node_storage_path: room.storage_path || storagePath,
label: room.name,
type: 'workspace',
}; };
}; };
// ... rest of the state interface ...
}
export const useNavigationStore = create<NavigationStore>((set, get) => ({ return {
_accessToken: null,
_userId: null,
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
context: initialState, context: initialState,
isLoading: false, isLoading: false,
error: null, error: null,
switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | 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 { 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;
}
logger.debug('navigation-context', '🔄 Starting context switch', {
from: {
main: get().context.main,
base: get().context.base,
extended: contextSwitch.extended,
nodeId: get().context.node?.id
},
to: {
main: contextSwitch.main,
base: contextSwitch.base,
extended: contextSwitch.extended
},
skipBaseContextLoad: contextSwitch.skipBaseContextLoad
});
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
const currentState = get().context; const currentState = get().context;
let newState: NavigationContextState = { ...currentState, node: null };
// 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) { if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main }); newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) { if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main); 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) { if (contextSwitch.base) {
newState = validateContextTransition(newState, { base: 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 || const targetContext = contextSwitch.base ||
contextSwitch.extended || contextSwitch.extended ||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : (contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
newState.base);
// Get database name const defaultNode = await getOrCreateDefaultRoom(targetContext);
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); const newHistory = addToHistory(currentState.history, defaultNode);
logger.debug('history-management', '📚 History updated', {
previousState: currentState.history,
newState: newHistory,
addedNode: defaultNode
});
set({ set({
context: { context: { ...newState, node: defaultNode, history: newHistory },
...newState,
node: defaultNode,
history: newHistory
},
isLoading: false, isLoading: false,
error: null error: null,
}); });
logger.debug('navigation-context', '✅ Context switch complete', {
logger.debug('navigation-context', '✅ Context switch completed', { main: newState.main, base: newState.base, nodeId: defaultNode.id,
finalState: {
main: newState.main,
base: newState.base,
nodeId: defaultNode.id
}
}); });
} catch (error) { } catch (error) {
logger.error('navigation-context', '❌ Failed to switch context:', error); logger.error('navigation-context', '❌ switchContext failed', error);
set({ set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false });
error: error instanceof Error ? error.message : 'Failed to switch context',
isLoading: false
});
} }
}, },
@ -249,14 +192,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
const currentState = get().context; const currentState = get().context;
if (currentState.history.currentIndex > 0) { if (currentState.history.currentIndex > 0) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1); const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
const node = getCurrentHistoryNode(newHistory); set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
set({
context: {
...currentState,
node,
history: newHistory
}
});
} }
}, },
@ -264,176 +200,139 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
const currentState = get().context; const currentState = get().context;
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) { if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1); const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
const node = getCurrentHistoryNode(newHistory); set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
set({
context: {
...currentState,
node,
history: newHistory
}
});
} }
}, },
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => { setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
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 existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
if (existingIndex !== -1) {
// If node exists in history, just navigate to it const newHistory = navigateHistory(currentState.history, existingIndex);
if (existingNodeIndex !== -1) { set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false });
logger.debug('navigation', '📍 Navigating to existing node in history', {
nodeId,
historyIndex: existingNodeIndex,
currentIndex: currentState.history.currentIndex
});
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
});
return; return;
} }
// Fetch new node data const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName); query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`,
if (!nodeData) { });
throw new Error(`Node not found: ${nodeId}`); if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`);
}
const room = rooms[0];
const node: NavigationNode = { const node: NavigationNode = {
id: nodeId, id: room.id,
node_storage_path: nodeData.node_data.node_storage_path || '', node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`,
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId, label: room.name,
type: nodeData.node_type type: 'workspace',
}; };
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); const newHistory = addToHistory(currentState.history, node);
set({ set({ context: { ...currentState, node, history: newHistory }, isLoading: false });
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
});
} catch (error) { } catch (error) {
logger.error('navigation', '❌ Failed to navigate:', error); logger.error('navigation', '❌ navigate failed', error);
set({ set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false });
error: error instanceof Error ? error.message : 'Failed to navigate',
isLoading: false
});
} }
}, },
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => { 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 { try {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
const currentNode = get().context.node;
if (!isValidNodeType(node.type)) { if (currentNode && get()._accessToken) {
throw new Error(`Invalid node type: ${node.type}`); const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
} query: `id=eq.${currentNode.id}`,
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
}); });
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 });
} }
}, },
refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => { navigateToNeoNode: async (neoNode: NeoGraphNode) => {
const userId = get()._userId;
if (!userId || !get()._accessToken) {
logger.warn('navigation', '⚠️ navigateToNeoNode called without auth');
return;
}
try { try {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
const currentState = get().context; const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`,
if (currentState.node) { });
const dbName = getContextDatabase(currentState, userDbName, workerDbName); let room: WhiteboardRoom;
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName); if (existing && existing.length > 0) {
if (nodeData) { 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 = { const node: NavigationNode = {
id: currentState.node.id, id: room.id,
node_storage_path: nodeData.node_data.node_storage_path || '', node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`,
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id, label: room.name,
type: nodeData.node_type type: neoNode.node_type,
}; };
set({ const currentState = get().context;
context: { const newHistory = addToHistory(currentState.history, node);
...currentState, set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null });
node logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode });
}
});
}
}
set({ isLoading: false });
} catch (error) { } catch (error) {
logger.error('navigation', '❌ Failed to refresh navigation state:', error); logger.error('navigation', '❌ navigateToNeoNode failed', error);
set({ set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false });
error: error instanceof Error ? error.message : 'Failed to refresh navigation state',
isLoading: false
});
} }
} },
})); };
});

View File

@ -1,5 +1,4 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { supabase } from '../supabaseClient';
export interface TranscriptionSegment { export interface TranscriptionSegment {
text: string; text: string;
@ -168,7 +167,37 @@ 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.
const pgFetch = async <T = any>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
table: string,
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
): Promise<T | null> => {
const token = get()._accessToken;
if (!token) throw new Error('pgFetch: no access token');
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`;
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
};
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>;
};
return {
isRecording: false, isRecording: false,
isConnecting: false, isConnecting: false,
activeSession: null, activeSession: null,
@ -209,10 +238,9 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
startSession: async (timetableTag?: TimetablePeriod) => { startSession: async (timetableTag?: TimetablePeriod) => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
// Create session in Supabase
try { try {
const { _accessToken: token, _userId: userId } = get(); const { _userId: userId } = get();
if (!token || !userId) { if (!userId) {
console.error('No authenticated user'); console.error('No authenticated user');
return; return;
} }
@ -227,14 +255,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
auto_tagged: !!timetableTag, auto_tagged: !!timetableTag,
}; };
const { data, error } = await supabase const data = await pgFetch<TranscriptionSession>('POST', 'transcription_sessions', {
.from('transcription_sessions') body: sessionData,
.insert(sessionData) prefer: 'return=representation',
.select() single: true,
.single(); });
if (error) { if (!data) {
console.error('Failed to create session:', error); console.error('Failed to create session: no data returned');
return; return;
} }
@ -255,14 +283,16 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
const idx = newCompleted.length; const idx = newCompleted.length;
newCompleted.push({ ...currentSegment, isFinal: true }); newCompleted.push({ ...currentSegment, isFinal: true });
if (activeSession) { if (activeSession) {
supabase.from('transcription_segments').insert({ pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id, session_id: activeSession.id,
sequence_index: idx, sequence_index: idx,
text: currentSegment.text, text: currentSegment.text,
start_seconds: currentSegment.start, start_seconds: currentSegment.start,
end_seconds: currentSegment.end, end_seconds: currentSegment.end,
is_final: true, is_final: true,
}).then(({ error }) => { if (error) console.error('Failed to save live segment on stop:', error); }); },
}).catch(err => console.error('Failed to save live segment on stop:', err));
} }
} }
} }
@ -274,14 +304,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
if (activeSession) { if (activeSession) {
try { try {
await supabase await pgFetch('PATCH', 'transcription_sessions', {
.from('transcription_sessions') query: `id=eq.${activeSession.id}`,
.update({ body: {
ended_at: new Date().toISOString(), ended_at: new Date().toISOString(),
word_count: finalWordCount, word_count: finalWordCount,
segment_count: newCompleted.length, segment_count: newCompleted.length,
}) },
.eq('id', activeSession.id); });
} catch (error) { } catch (error) {
console.error('Failed to end session:', error); console.error('Failed to end session:', error);
} }
@ -339,14 +369,16 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
const elapsed = get().elapsedSeconds; const elapsed = get().elapsedSeconds;
for (const { seg, idx } of toSave) { for (const { seg, idx } of toSave) {
if (activeSession) { if (activeSession) {
supabase.from('transcription_segments').insert({ pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id, session_id: activeSession.id,
sequence_index: idx, sequence_index: idx,
text: seg.text, text: seg.text,
start_seconds: seg.start, start_seconds: seg.start,
end_seconds: seg.end, end_seconds: seg.end,
is_final: true, is_final: true,
}).then(({ error }) => { if (error) console.error('Failed to save segment:', error); }); },
}).catch(err => console.error('Failed to save segment:', err));
} }
get().checkSegmentForKeywords(seg.text, elapsed); get().checkSegmentForKeywords(seg.text, elapsed);
} }
@ -402,13 +434,15 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
if (isNew && activeSession) { if (isNew && activeSession) {
try { try {
await supabase.from('transcription_segments').insert({ await pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id, session_id: activeSession.id,
sequence_index: newCompleted.length - 1, sequence_index: newCompleted.length - 1,
text, text,
start_seconds: metadata.start, start_seconds: metadata.start,
end_seconds: metadata.end, end_seconds: metadata.end,
is_final: true, is_final: true,
},
}); });
} catch (error) { } catch (error) {
console.error('Failed to save segment:', error); console.error('Failed to save segment:', error);
@ -425,14 +459,16 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
); );
set({ completedSegments: autoCompleted, wordCount: autoWordCount }); set({ completedSegments: autoCompleted, wordCount: autoWordCount });
if (activeSession) { if (activeSession) {
supabase.from('transcription_segments').insert({ pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id, session_id: activeSession.id,
sequence_index: autoCompleted.length - 1, sequence_index: autoCompleted.length - 1,
text: currentSegment.text, text: currentSegment.text,
start_seconds: currentSegment.start, start_seconds: currentSegment.start,
end_seconds: currentSegment.end, end_seconds: currentSegment.end,
is_final: true, is_final: true,
}).then(({ error }) => { if (error) console.error('Failed to save auto-committed segment:', error); }); },
}).catch(err => console.error('Failed to save auto-committed segment:', err));
} }
} }
set({ currentSegment: { text, isFinal: false, ...metadata } }); set({ currentSegment: { text, isFinal: false, ...metadata } });
@ -474,7 +510,8 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
try { try {
for (const event of eventsToFlush) { for (const event of eventsToFlush) {
await supabase.from('canvas_events').insert({ await pgFetch('POST', 'canvas_events', {
body: {
session_id: activeSession?.id || null, session_id: activeSession?.id || null,
user_id: get()._userId || '', user_id: get()._userId || '',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -484,6 +521,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
canvas_snapshot_url: event.snapshotUrl || null, canvas_snapshot_url: event.snapshotUrl || null,
tldraw_page_id: event.pageId || null, tldraw_page_id: event.pageId || null,
tldraw_shape_ids: event.shapeIds || null, tldraw_shape_ids: event.shapeIds || null,
},
}); });
} }
@ -498,17 +536,9 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
const { _userId: userId } = get(); const { _userId: userId } = get();
if (!userId) return []; if (!userId) return [];
const { data, error } = await supabase const data = await pgFetch<TranscriptionSession[]>('GET', 'transcription_sessions', {
.from('transcription_sessions') query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`,
.select('*') });
.eq('user_id', userId)
.order('started_at', { ascending: false })
.limit(50);
if (error) {
console.error('Failed to load sessions:', error);
return [];
}
return data || []; return data || [];
} catch (error) { } catch (error) {
@ -678,13 +708,13 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
if (activeSession) { if (activeSession) {
try { try {
const { _accessToken: _kwToken } = get(); const { _accessToken: kwToken } = get();
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`, { await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(_kwToken ? { 'Authorization': `Bearer ${_kwToken}` } : {}), ...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: activeSession.id, session_id: activeSession.id,
@ -709,4 +739,5 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
clearKeywordMatches: () => { clearKeywordMatches: () => {
set({ keywordMatches: [] }); set({ keywordMatches: [] });
}, },
})); };
});

View File

@ -227,6 +227,13 @@ export interface UnifiedContextSwitch {
} }
// Navigation Actions Interface // Navigation Actions Interface
export interface NeoGraphNode {
neo4j_node_id: string;
neo4j_db_name: string;
node_type: string;
label: string;
}
export interface NavigationActions { export interface NavigationActions {
// Unified Context Switch // Unified Context Switch
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>; switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
@ -239,6 +246,7 @@ export interface NavigationActions {
// Node Navigation // Node Navigation
navigate: (nodeId: string, dbName: string) => Promise<void>; navigate: (nodeId: string, dbName: string) => Promise<void>;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>; navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
navigateToNeoNode: (neoNode: NeoGraphNode) => Promise<void>;
// History Navigation // History Navigation
goBack: () => void; goBack: () => void;

View File

@ -7,6 +7,14 @@ export interface TranscriptionConfig {
useVad?: boolean; useVad?: boolean;
} }
export interface ServerSegment {
text: string;
start: number;
end: number;
}
type ServerSegmentsCallback = (segments: ServerSegment[], isLastLive: boolean) => void;
export class TranscriptionService { export class TranscriptionService {
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private stream: MediaStream | null = null; private stream: MediaStream | null = null;
@ -14,27 +22,29 @@ export class TranscriptionService {
private mediaStreamSource: MediaStreamAudioSourceNode | null = null; private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
private workletNode: AudioWorkletNode | null = null; private workletNode: AudioWorkletNode | null = null;
private selectedDeviceId: string = ''; private selectedDeviceId: string = '';
private finalizedSegmentCount: number = 0; private intentionalStop: boolean = false;
private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null; private onServerSegments: ServerSegmentsCallback | null = null;
private onDisconnect: (() => void) | null = null;
constructor(deviceId: string = '') { constructor(deviceId: string = '') {
this.selectedDeviceId = deviceId; this.selectedDeviceId = deviceId;
} }
setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) { setServerSegmentsCallback(callback: ServerSegmentsCallback) {
this.onTranscriptionUpdate = callback; this.onServerSegments = callback;
}
setDisconnectCallback(callback: () => void) {
this.onDisconnect = callback;
} }
async startTranscription(config: TranscriptionConfig = {}) { async startTranscription(config: TranscriptionConfig = {}) {
console.log('🎙️ Starting transcription service...'); console.log('🎙️ Starting transcription service...');
this.intentionalStop = false;
try { try {
logger.info('transcription-service', '🔊 Requesting microphone access...'); logger.info('transcription-service', '🔊 Requesting microphone access...');
// Call getUserMedia directly — this triggers the browser permission prompt.
// The old code called enumerateDevices() first to find a device ID, but
// without microphone permission deviceId is always (empty string, falsy),
// causing an early return that never prompted the user for permission.
const audioConstraints: MediaTrackConstraints = this.selectedDeviceId const audioConstraints: MediaTrackConstraints = this.selectedDeviceId
? { deviceId: { exact: this.selectedDeviceId } } ? { deviceId: { exact: this.selectedDeviceId } }
: { echoCancellation: true, noiseSuppression: true }; : { echoCancellation: true, noiseSuppression: true };
@ -60,13 +70,13 @@ export class TranscriptionService {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
logger.info('transcription-service', '✅ WebSocket connected'); logger.info('transcription-service', '✅ WebSocket connected');
// Send initial configuration — audio capture starts only after SERVER_READY.
ws.send(JSON.stringify({ ws.send(JSON.stringify({
uid: uuid, uid: uuid,
language: config.language || 'en', language: config.language || 'en',
task: config.task || 'transcribe', task: config.task || 'transcribe',
model: config.modelSize || 'base', model: config.modelSize || 'large-v3',
use_vad: config.useVad ?? true, use_vad: config.useVad ?? true,
max_connection_time: 7200, // server default is 600 s — set to 2 h
})); }));
}; };
@ -76,17 +86,18 @@ export class TranscriptionService {
ws.onclose = () => { ws.onclose = () => {
logger.info('transcription-service', '🔌 WebSocket closed'); logger.info('transcription-service', '🔌 WebSocket closed');
const wasIntentional = this.intentionalStop;
this.cleanup(); this.cleanup();
if (!wasIntentional && this.onDisconnect) {
this.onDisconnect();
}
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.uid !== uuid) { if (data.uid !== uuid) return;
return;
}
if (data.message === 'SERVER_READY') { if (data.message === 'SERVER_READY') {
// Server is ready — now safe to start streaming audio.
logger.info('transcription-service', '🟢 Server ready, starting audio capture'); logger.info('transcription-service', '🟢 Server ready, starting audio capture');
this.setupAudioProcessing(); this.setupAudioProcessing();
return; return;
@ -94,37 +105,29 @@ export class TranscriptionService {
if (data.status === 'WAIT') { if (data.status === 'WAIT') {
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`); logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
this.intentionalStop = true;
this.cleanup(); this.cleanup();
return; return;
} }
if (data.message === 'DISCONNECT') { if (data.message === 'DISCONNECT') {
logger.info('transcription-service', '🔕 Server requested disconnection'); logger.info('transcription-service', '🔕 Server requested disconnection');
this.intentionalStop = true;
this.cleanup(); this.cleanup();
return; return;
} }
if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) { // Pass the full segment window directly to the store — the store owns
const segments = data.segments; // all boundary and archival decisions, matching the WhisperLive reference
const lastIdx = segments.length - 1; // frontend which simply re-renders the server's authoritative segment list.
if (this.onServerSegments && data.segments && data.segments.length > 0) {
// Only emit segments we have not finalized yet — avoids re-processing the const segs: ServerSegment[] = data.segments.map((s: any) => ({
// full array on every message (which caused the stuck last segment bug). text: String(s.text ?? ''),
for (let i = this.finalizedSegmentCount; i < lastIdx; i++) { start: parseFloat(s.start ?? 0),
const seg = segments[i]; end: parseFloat(s.end ?? 0),
this.onTranscriptionUpdate(seg.text, true, { }));
start: parseFloat(seg.start), const isLastLive = !(data.segments[data.segments.length - 1]?.completed);
end: parseFloat(seg.end), this.onServerSegments(segs, isLastLive);
});
this.finalizedSegmentCount = i + 1;
}
// Always update the live (last) segment
const lastSeg = segments[lastIdx];
this.onTranscriptionUpdate(lastSeg.text, lastSeg.completed ?? false, {
start: parseFloat(lastSeg.start),
end: parseFloat(lastSeg.end),
});
} }
}; };
} catch (error) { } catch (error) {
@ -134,26 +137,18 @@ export class TranscriptionService {
} }
private async setupAudioProcessing() { private async setupAudioProcessing() {
if (!this.stream || !this.socket) { if (!this.stream || !this.socket) return;
return;
}
try { try {
// Request 16 kHz from the browser — it resamples natively so we send
// the correct rate to the server without any JS resampling overhead.
this.audioContext = new AudioContext({ sampleRate: 16000 }); this.audioContext = new AudioContext({ sampleRate: 16000 });
await this.audioContext.audioWorklet.addModule('/audioWorklet.js'); await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream); this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor'); this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
// The worklet accumulates 4096 samples (256 ms at 16 kHz) before posting,
// matching the reference frontend chunk size and eliminating the tiny-frame
// flood that was overwhelming the server during silence.
this.workletNode.port.onmessage = (event) => { this.workletNode.port.onmessage = (event) => {
if (this.socket?.readyState === WebSocket.OPEN) { if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(event.data); // event.data is a transferred ArrayBuffer this.socket.send(event.data);
} }
}; };
@ -165,7 +160,7 @@ export class TranscriptionService {
} }
stopTranscription() { stopTranscription() {
// Signal the server cleanly so it can finalise the last segment. this.intentionalStop = true;
if (this.socket?.readyState === WebSocket.OPEN) { if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send('END_OF_AUDIO'); this.socket.send('END_OF_AUDIO');
} }
@ -173,27 +168,22 @@ export class TranscriptionService {
} }
private cleanup() { private cleanup() {
this.finalizedSegmentCount = 0;
if (this.workletNode) { if (this.workletNode) {
this.workletNode.disconnect(); this.workletNode.disconnect();
this.workletNode = null; this.workletNode = null;
} }
if (this.mediaStreamSource) { if (this.mediaStreamSource) {
this.mediaStreamSource.disconnect(); this.mediaStreamSource.disconnect();
this.mediaStreamSource = null; this.mediaStreamSource = null;
} }
if (this.audioContext) { if (this.audioContext) {
this.audioContext.close(); this.audioContext.close();
this.audioContext = null; this.audioContext = null;
} }
if (this.stream) { if (this.stream) {
this.stream.getTracks().forEach(track => track.stop()); this.stream.getTracks().forEach(track => track.stop());
this.stream = null; this.stream = null;
} }
if (this.socket) { if (this.socket) {
this.socket.close(); this.socket.close();
this.socket = null; this.socket = null;

View File

@ -1,15 +1,49 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Close from '@mui/icons-material/Close';
import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore'; import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore';
const PROVIDERS = [ const PROVIDERS = [
{ value: 'openai', label: 'OpenAI' }, { value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' }, { value: 'anthropic', label: 'Anthropic' },
{ value: 'ollama', label: 'Ollama' }, { value: 'ollama', label: 'Ollama (local)' },
{ value: 'openrouter', label: 'OpenRouter' }, { value: 'openrouter', label: 'OpenRouter' },
{ value: 'google', label: 'Google' }, { value: 'google', label: 'Google Gemini' },
] as const; ] as const;
const WHISPER_MODELS = [
{ value: 'tiny', label: 'Tiny (fastest, least accurate)' },
{ value: 'tiny.en', label: 'Tiny English' },
{ value: 'base', label: 'Base' },
{ value: 'base.en', label: 'Base English' },
{ value: 'small', label: 'Small' },
{ value: 'small.en', label: 'Small English' },
{ value: 'medium', label: 'Medium' },
{ value: 'medium.en', label: 'Medium English' },
{ value: 'large-v2', label: 'Large v2' },
{ value: 'large-v3', label: 'Large v3 (best accuracy)' },
];
const fieldStyle: React.CSSProperties = {
width: '100%',
padding: '7px 10px',
border: '1px solid var(--color-divider)',
borderRadius: '6px',
backgroundColor: 'var(--color-muted)',
color: 'var(--color-text)',
fontSize: '13px',
outline: 'none',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '12px',
fontWeight: 600,
color: 'var(--color-text-2)',
marginBottom: '4px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => { const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
const { llmConfig, setLLMConfig } = useTranscriptionStore(); const { llmConfig, setLLMConfig } = useTranscriptionStore();
const [form, setForm] = useState<LLMConfig>(llmConfig); const [form, setForm] = useState<LLMConfig>(llmConfig);
@ -25,97 +59,196 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is
const handleSave = () => { const handleSave = () => {
setLLMConfig(form); setLLMConfig(form);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => {
setSaved(false);
onClose();
}, 1000);
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
{/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/50 transition-opacity" style={{
onClick={onClose} position: 'fixed',
/> inset: 0,
zIndex: 99999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
{/* Backdrop */}
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
{/* Modal panel */} {/* Modal panel */}
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full max-w-md mx-auto"> <div
style={{
position: 'relative',
width: '100%',
maxWidth: '420px',
backgroundColor: 'var(--color-panel)',
border: '1px solid var(--color-divider)',
borderRadius: '10px',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
overflow: 'hidden',
zIndex: 1,
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200"> <div style={{
<h3 className="text-lg font-medium leading-6 text-gray-900"> padding: '14px 16px',
LLM Provider Settings borderBottom: '1px solid var(--color-divider)',
</h3> display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Settings
</span>
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none" style={{
padding: '2px 6px',
border: 'none',
backgroundColor: 'transparent',
color: 'var(--color-text-2)',
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1,
}}
> >
<Close sx={{ fontSize: 20 }} /> ×
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="px-4 py-5 sm:p-6 space-y-4"> <div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '80vh', overflowY: 'auto' }}>
{/* Provider dropdown */}
{/* ── Transcription section ── */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <div style={{
Provider fontSize: '11px',
</label> fontWeight: 700,
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '10px',
paddingBottom: '6px',
borderBottom: '1px solid var(--color-divider)',
}}>
Transcription
</div>
<label style={labelStyle}>Whisper Model</label>
<select
value={form.whisperModel || 'large-v3'}
onChange={(e) => setForm({ ...form, whisperModel: e.target.value })}
style={fieldStyle}
>
{WHISPER_MODELS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</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>
{/* ── LLM section ── */}
<div>
<div style={{
fontSize: '11px',
fontWeight: 700,
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '10px',
paddingBottom: '6px',
borderBottom: '1px solid var(--color-divider)',
}}>
AI Summary Provider
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={labelStyle}>Provider</label>
<select <select
value={form.provider} value={form.provider}
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })} onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" style={fieldStyle}
> >
{PROVIDERS.map((p) => ( {PROVIDERS.map((p) => (
<option key={p.value} value={p.value}> <option key={p.value} value={p.value}>{p.label}</option>
{p.label}
</option>
))} ))}
</select> </select>
</div> </div>
{/* Model name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label style={labelStyle}>Model</label>
Model
</label>
<input <input
type="text" type="text"
value={form.model} value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })} onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514" placeholder={
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" 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> </div>
{/* API Key */} {form.provider === 'ollama' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label style={labelStyle}>Ollama Base URL</label>
API Key <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> </label>
<input <input
type="password" type="password"
value={form.apiKey} value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })} onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder="sk-..." placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : '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" style={fieldStyle}
/> />
</div> </div>
</div>
{/* Note */} <div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '8px' }}>
<p className="text-xs text-gray-500"> API keys are stored in your browser only.
API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server. </div>
</p> </div>
{/* Save button */} {/* Save button */}
<button <button
onClick={handleSave} onClick={handleSave}
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${ style={{
saved padding: '9px',
? 'bg-green-600 hover:bg-green-700' border: 'none',
: 'bg-blue-600 hover:bg-blue-700' borderRadius: '6px',
}`} backgroundColor: saved ? '#16a34a' : '#2563eb',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
transition: 'background-color 200ms',
}}
> >
{saved ? '✓ Saved!' : 'Save Settings'} {saved ? '✓ Saved' : 'Save Settings'}
</button> </button>
</div> </div>
</div> </div>