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:
parent
3a65cf436b
commit
b0c7758135
@ -1,333 +1,60 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Box,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Button,
|
||||
styled
|
||||
IconButton, Tooltip, Box, Menu, MenuItem,
|
||||
ListItemIcon, ListItemText, Chip, styled,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
History as HistoryIcon,
|
||||
School as SchoolIcon,
|
||||
Person as PersonIcon,
|
||||
AccountCircle as AccountCircleIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
School as TeachingIcon,
|
||||
Business as BusinessIcon,
|
||||
AccountTree as DepartmentIcon,
|
||||
Class as ClassIcon,
|
||||
ExpandMore as ExpandMoreIcon
|
||||
Home as HomeIcon,
|
||||
CalendarToday,
|
||||
DateRange,
|
||||
Event,
|
||||
WorkspacesOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigationStore } from '../../stores/navigationStore';
|
||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
||||
import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts';
|
||||
import {
|
||||
BaseContext,
|
||||
ViewContext
|
||||
} from '../../types/navigation';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const NavigationRoot = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const NavigationControls = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const ContextToggleContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(0.5),
|
||||
gap: theme.spacing(0.5),
|
||||
'& .button-label': {
|
||||
'@media (max-width: 500px)': {
|
||||
display: 'none'
|
||||
function getNodeIcon(nodeType: string) {
|
||||
switch (nodeType) {
|
||||
case 'User': return <HomeIcon fontSize="small" />;
|
||||
case 'CalendarYear': return <CalendarToday fontSize="small" />;
|
||||
case 'CalendarMonth': return <DateRange fontSize="small" />;
|
||||
case 'CalendarDay': return <Event fontSize="small" />;
|
||||
default: return <WorkspacesOutlined fontSize="small" />;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const ContextToggleButton = styled(Button, {
|
||||
shouldForwardProp: (prop) => prop !== 'active'
|
||||
})<{ active?: boolean }>(({ theme, active }) => ({
|
||||
minWidth: 0,
|
||||
padding: theme.spacing(0.5, 1.5),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: active ? theme.palette.primary.main : 'transparent',
|
||||
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
|
||||
textTransform: 'none',
|
||||
transition: theme.transitions.create(['background-color', 'color'], {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
'&:hover': {
|
||||
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
|
||||
},
|
||||
'@media (max-width: 500px)': {
|
||||
padding: theme.spacing(0.5),
|
||||
}
|
||||
}));
|
||||
|
||||
export const GraphNavigator: React.FC = () => {
|
||||
const {
|
||||
context,
|
||||
switchContext,
|
||||
goBack,
|
||||
goForward,
|
||||
isLoading
|
||||
} = useNavigationStore();
|
||||
|
||||
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
|
||||
|
||||
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const { context, goBack, goForward, isLoading } = useNavigationStore();
|
||||
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [availableWidth, setAvailableWidth] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateAvailableSpace = () => {
|
||||
if (!rootRef.current) return;
|
||||
|
||||
// Get the header element
|
||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
||||
if (!header) return;
|
||||
|
||||
// Get the title and menu elements
|
||||
const title = header.querySelector('.app-title');
|
||||
const menu = header.querySelector('.menu-button');
|
||||
|
||||
if (!title || !menu) return;
|
||||
|
||||
// Calculate available width
|
||||
const headerWidth = header.clientWidth;
|
||||
const titleWidth = title.clientWidth;
|
||||
const menuWidth = menu.clientWidth;
|
||||
const padding = 48; // Increased buffer space
|
||||
|
||||
const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding;
|
||||
console.log('Available width:', newAvailableWidth); // Debug log
|
||||
setAvailableWidth(newAvailableWidth);
|
||||
};
|
||||
|
||||
// Set up ResizeObserver
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// Use requestAnimationFrame to debounce calculations
|
||||
window.requestAnimationFrame(calculateAvailableSpace);
|
||||
});
|
||||
|
||||
// Observe both the root element and the header
|
||||
if (rootRef.current) {
|
||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
||||
if (header) {
|
||||
resizeObserver.observe(header);
|
||||
resizeObserver.observe(rootRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
calculateAvailableSpace();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper function to determine what should be visible
|
||||
const getVisibility = () => {
|
||||
// Adjusted thresholds and collapse order:
|
||||
// 1. Navigation controls (back/forward/history) collapse first
|
||||
// 2. Toggle labels collapse second
|
||||
// 3. Context label collapses last
|
||||
if (availableWidth < 300) {
|
||||
return {
|
||||
navigation: false,
|
||||
contextLabel: true, // Keep context label visible longer
|
||||
toggleLabels: false
|
||||
};
|
||||
} else if (availableWidth < 450) {
|
||||
return {
|
||||
navigation: false,
|
||||
contextLabel: true, // Keep context label visible
|
||||
toggleLabels: true
|
||||
};
|
||||
} else if (availableWidth < 600) {
|
||||
return {
|
||||
navigation: true,
|
||||
contextLabel: true,
|
||||
toggleLabels: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
navigation: true,
|
||||
contextLabel: true,
|
||||
toggleLabels: true
|
||||
};
|
||||
};
|
||||
|
||||
const visibility = getVisibility();
|
||||
|
||||
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setHistoryMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleHistoryClose = () => {
|
||||
setHistoryMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleHistoryItemClick = (index: number) => {
|
||||
const {currentIndex} = context.history;
|
||||
const steps = index - currentIndex;
|
||||
|
||||
if (steps < 0) {
|
||||
for (let i = 0; i < -steps; i++) {
|
||||
goBack();
|
||||
}
|
||||
} else if (steps > 0) {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
goForward();
|
||||
}
|
||||
}
|
||||
|
||||
handleHistoryClose();
|
||||
};
|
||||
|
||||
const handleContextChange = useCallback(async (newContext: BaseContext) => {
|
||||
try {
|
||||
// Check if trying to access institute contexts without worker database
|
||||
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
|
||||
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
|
||||
return;
|
||||
}
|
||||
// Check if trying to access profile contexts without user database
|
||||
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
|
||||
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('navigation', '🔄 Changing main context', {
|
||||
from: context.main,
|
||||
to: newContext,
|
||||
userDbName,
|
||||
workerDbName
|
||||
});
|
||||
|
||||
// Get default view for new context
|
||||
const defaultView = getDefaultViewForContext(newContext);
|
||||
|
||||
// Use unified context switch with both base and extended contexts
|
||||
await switchContext({
|
||||
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
|
||||
base: newContext,
|
||||
extended: defaultView,
|
||||
skipBaseContextLoad: false
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to change context:', error);
|
||||
}
|
||||
}, [context.main, switchContext, userDbName, workerDbName]);
|
||||
|
||||
// Helper function to get default view for a context
|
||||
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
|
||||
switch (context) {
|
||||
case 'calendar':
|
||||
return 'overview';
|
||||
case 'teaching':
|
||||
return 'overview';
|
||||
case 'school':
|
||||
return 'overview';
|
||||
case 'department':
|
||||
return 'overview';
|
||||
case 'class':
|
||||
return 'overview';
|
||||
default:
|
||||
return 'overview';
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setContextMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleContextSelect = useCallback(async (context: BaseContext) => {
|
||||
setContextMenuAnchor(null);
|
||||
try {
|
||||
// Use unified context switch with both base and extended contexts
|
||||
const contextDef = NAVIGATION_CONTEXTS[context];
|
||||
const defaultExtended = contextDef?.views[0]?.id;
|
||||
|
||||
await switchContext({
|
||||
base: context,
|
||||
extended: defaultExtended
|
||||
}, userDbName, workerDbName);
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to select context:', error);
|
||||
}
|
||||
}, [switchContext, userDbName, workerDbName]);
|
||||
|
||||
const getContextItems = useCallback(() => {
|
||||
if (context.main === 'profile') {
|
||||
return [
|
||||
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
|
||||
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
|
||||
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{ id: 'school', label: 'School', icon: BusinessIcon },
|
||||
{ id: 'department', label: 'Department', icon: DepartmentIcon },
|
||||
{ id: 'class', label: 'Class', icon: ClassIcon },
|
||||
];
|
||||
}
|
||||
}, [context.main]);
|
||||
|
||||
const getContextIcon = useCallback((contextType: string) => {
|
||||
switch (contextType) {
|
||||
case 'profile':
|
||||
return <AccountCircleIcon />;
|
||||
case 'calendar':
|
||||
return <CalendarIcon />;
|
||||
case 'teaching':
|
||||
return <TeachingIcon />;
|
||||
case 'school':
|
||||
return <BusinessIcon />;
|
||||
case 'department':
|
||||
return <DepartmentIcon />;
|
||||
case 'class':
|
||||
return <ClassIcon />;
|
||||
default:
|
||||
return <AccountCircleIcon />;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isDisabled = !isNeoUserInitialized || isLoading;
|
||||
const { history } = context;
|
||||
const canGoBack = history.currentIndex > 0;
|
||||
const canGoForward = history.currentIndex < history.nodes.length - 1;
|
||||
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 (
|
||||
<NavigationRoot ref={rootRef}>
|
||||
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
|
||||
<NavigationRoot>
|
||||
<Tooltip title="Back">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
@ -337,7 +64,7 @@ export const GraphNavigator: React.FC = () => {
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={handleHistoryClick}
|
||||
disabled={!history.nodes.length || isDisabled}
|
||||
disabled={!history.nodes.length}
|
||||
size="small"
|
||||
>
|
||||
<HistoryIcon fontSize="small" />
|
||||
@ -347,112 +74,48 @@ export const GraphNavigator: React.FC = () => {
|
||||
|
||||
<Tooltip title="Forward">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</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
|
||||
anchorEl={historyMenuAnchor}
|
||||
open={Boolean(historyMenuAnchor)}
|
||||
onClose={handleHistoryClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
{history.nodes.map((node, index) => (
|
||||
<MenuItem
|
||||
key={`${node.id}-${index}`}
|
||||
onClick={() => handleHistoryItemClick(index)}
|
||||
selected={index === history.currentIndex}
|
||||
dense
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getContextIcon(node.type)}
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{getNodeIcon(node.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={node.label || node.id}
|
||||
secondary={node.type}
|
||||
primaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||
secondaryTypographyProps={{ fontSize: '0.7rem' }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<ContextToggleContainer>
|
||||
<ContextToggleButton
|
||||
active={context.main === 'profile'}
|
||||
onClick={() => handleContextChange('profile' as BaseContext)}
|
||||
startIcon={<PersonIcon />}
|
||||
disabled={isDisabled || !userDbName}
|
||||
>
|
||||
{visibility.toggleLabels && <span className="button-label">Profile</span>}
|
||||
</ContextToggleButton>
|
||||
<ContextToggleButton
|
||||
active={context.main === 'institute'}
|
||||
onClick={() => handleContextChange('school' as BaseContext)}
|
||||
startIcon={<SchoolIcon />}
|
||||
disabled={isDisabled || !workerDbName}
|
||||
>
|
||||
{visibility.toggleLabels && <span className="button-label">Institute</span>}
|
||||
</ContextToggleButton>
|
||||
</ContextToggleContainer>
|
||||
|
||||
<Box>
|
||||
<Tooltip title={context.base}>
|
||||
<span>
|
||||
<Button
|
||||
onClick={handleContextMenu}
|
||||
disabled={isDisabled}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
p: 0.5,
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getContextIcon(context.base)}
|
||||
{visibility.contextLabel && (
|
||||
<Box sx={{ ml: 1 }}>
|
||||
{context.base}
|
||||
</Box>
|
||||
)}
|
||||
<ExpandMoreIcon sx={{ ml: visibility.contextLabel ? 0.5 : 0 }} />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={contextMenuAnchor}
|
||||
open={Boolean(contextMenuAnchor)}
|
||||
onClose={() => setContextMenuAnchor(null)}
|
||||
>
|
||||
{getContextItems().map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleContextSelect(item.id as BaseContext)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<item.icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</NavigationRoot>
|
||||
);
|
||||
};
|
||||
225
src/components/navigation/GraphSidebar.tsx
Normal file
225
src/components/navigation/GraphSidebar.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, IconButton, CircularProgress, Tooltip, Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ChevronLeft, ChevronRight,
|
||||
ExpandMore, ChevronRight as ChevronRightIcon,
|
||||
Home as HomeIcon, CalendarToday, DateRange, Event,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigationStore } from '../../stores/navigationStore';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { NeoGraphNode } from '../../types/navigation';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
interface TreeNode extends NeoGraphNode {
|
||||
has_children?: boolean;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
const NODE_ICONS: Record<string, React.ElementType> = {
|
||||
User: HomeIcon,
|
||||
CalendarYear: CalendarToday,
|
||||
CalendarMonth: DateRange,
|
||||
CalendarWeek: DateRange,
|
||||
CalendarDay: Event,
|
||||
};
|
||||
|
||||
const SIDEBAR_WIDTH = 220;
|
||||
|
||||
interface TreeItemProps {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
onSelect: (node: TreeNode) => void;
|
||||
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
|
||||
activeRoomId?: string;
|
||||
}
|
||||
|
||||
function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const Icon = NODE_ICONS[node.node_type] || HomeIcon;
|
||||
const canExpand = node.has_children !== false && node.node_type !== 'CalendarDay';
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!expanded && children.length === 0 && canExpand) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const loaded = await onExpand(node);
|
||||
setChildren(loaded);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
setExpanded(v => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
onClick={() => onSelect(node)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.4,
|
||||
cursor: 'pointer', borderRadius: 1, mx: 0.5,
|
||||
fontSize: '0.78rem', minHeight: 28,
|
||||
bgcolor: 'transparent',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{canExpand && (
|
||||
loading
|
||||
? <CircularProgress size={10} />
|
||||
: (
|
||||
<IconButton
|
||||
size="small" sx={{ p: 0, color: 'text.secondary' }}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{expanded
|
||||
? <ExpandMore sx={{ fontSize: 14 }} />
|
||||
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Icon sx={{ fontSize: 14, mr: 0.75, flexShrink: 0, color: 'text.secondary' }} />
|
||||
<Box sx={{
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
flexGrow: 1, color: 'text.primary',
|
||||
}}>
|
||||
{node.label}
|
||||
</Box>
|
||||
</Box>
|
||||
{canExpand && (
|
||||
<Collapse in={expanded} timeout="auto">
|
||||
{children.map(child => (
|
||||
<TreeItem
|
||||
key={child.neo4j_node_id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onSelect={onSelect}
|
||||
onExpand={onExpand}
|
||||
activeRoomId={activeRoomId}
|
||||
/>
|
||||
))}
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface GraphSidebarProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function GraphSidebar({ open, onToggle }: GraphSidebarProps) {
|
||||
const { accessToken } = useAuth();
|
||||
const { navigateToNeoNode, context } = useNavigationStore();
|
||||
const [tree, setTree] = useState<TreeNode | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||
|
||||
const fetchTree = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/graph/tree`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Graph tree fetch failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setTree(data.tree);
|
||||
} catch (err) {
|
||||
logger.error('graph-sidebar', 'Failed to load graph tree', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, apiBase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !tree && accessToken) fetchTree();
|
||||
}, [open, tree, accessToken, fetchTree]);
|
||||
|
||||
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
|
||||
if (!accessToken) return [];
|
||||
const params = new URLSearchParams({
|
||||
neo4j_node_id: node.neo4j_node_id,
|
||||
neo4j_db_name: node.neo4j_db_name,
|
||||
node_type: node.node_type,
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.children || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [accessToken, apiBase]);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', height: '100%', display: 'flex', flexShrink: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: open ? SIDEBAR_WIDTH : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'width 0.2s ease',
|
||||
bgcolor: 'background.paper',
|
||||
borderRight: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ overflowY: 'auto', flexGrow: 1, pt: 1 }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
) : tree ? (
|
||||
<TreeItem
|
||||
node={tree}
|
||||
depth={0}
|
||||
onSelect={n => navigateToNeoNode(n)}
|
||||
onExpand={handleExpand}
|
||||
activeRoomId={context.node?.id}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Tooltip title={open ? 'Collapse sidebar' : 'Expand sidebar'} placement="right">
|
||||
<IconButton
|
||||
onClick={onToggle}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: -14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
bgcolor: 'background.paper',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
width: 22,
|
||||
height: 44,
|
||||
borderRadius: '0 4px 4px 0',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
{open
|
||||
? <ChevronLeft sx={{ fontSize: 14 }} />
|
||||
: <ChevronRight sx={{ fontSize: 14 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -33,9 +33,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<CCUser | null>(null);
|
||||
const [user_role, setUserRole] = useState<string | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||
|
||||
const persistSession = useCallback((session: Session | null) => {
|
||||
if (session) {
|
||||
storageService.set(StorageKeys.SUPABASE_SESSION, session);
|
||||
@ -69,18 +71,47 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
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(() => {
|
||||
// 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(
|
||||
async (event, session) => {
|
||||
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
|
||||
|
||||
switch (event) {
|
||||
case 'INITIAL_SESSION':
|
||||
case 'SIGNED_IN':
|
||||
case 'TOKEN_REFRESHED': {
|
||||
if (event === 'SIGNED_IN') {
|
||||
persistSession(session ?? null);
|
||||
if (session?.user) {
|
||||
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);
|
||||
if (session?.user) {
|
||||
try {
|
||||
@ -100,26 +131,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUserRole(null);
|
||||
setAccessToken(null);
|
||||
}
|
||||
// Always clear loading after the first auth event resolves
|
||||
setLoading(false);
|
||||
break;
|
||||
return;
|
||||
}
|
||||
case 'SIGNED_OUT': {
|
||||
|
||||
if (event === 'SIGNED_OUT') {
|
||||
persistSession(null);
|
||||
setUser(null);
|
||||
setUserRole(null);
|
||||
setAccessToken(null);
|
||||
setLoading(false);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [buildUserFromSupabase, persistSession]);
|
||||
}, [buildUserFromSupabase, persistSession, triggerUserInit]);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
|
||||
@ -3,7 +3,6 @@ import { useAuth } from './AuthContext';
|
||||
import { useUser } from './UserContext';
|
||||
import { logger } from '../debugConfig';
|
||||
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 { useNavigationStore } from '../stores/navigationStore';
|
||||
|
||||
@ -131,7 +130,7 @@ const NeoUserContext = createContext<NeoUserContextType>({
|
||||
});
|
||||
|
||||
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const { user, accessToken } = useAuth();
|
||||
const { profile, isInitialized: isUserInitialized } = useUser();
|
||||
const navigationStore = useNavigationStore();
|
||||
|
||||
@ -215,12 +214,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Set database names
|
||||
const userDb = profile.user_db_name || (user?.email ?
|
||||
DatabaseNameService.getStoredUserDatabase() || null : null);
|
||||
|
||||
if (!userDb) {
|
||||
throw new Error('No user database name available');
|
||||
// Inject auth into navigation store so Supabase queries work
|
||||
if (user?.id && accessToken) {
|
||||
navigationStore.setAuthInfo(accessToken, user.id);
|
||||
}
|
||||
|
||||
// Initialize user node in profile context
|
||||
@ -236,7 +232,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
main: 'profile',
|
||||
base: 'profile',
|
||||
extended: 'overview'
|
||||
}, userDb, profile.school_db_name),
|
||||
}, null, null),
|
||||
switchTimeout
|
||||
]);
|
||||
|
||||
@ -271,9 +267,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
// Continue without user node - this is not critical for basic functionality
|
||||
}
|
||||
|
||||
// Set final state
|
||||
setUserDbName(userDb);
|
||||
setWorkerDbName(profile.school_db_name);
|
||||
// Set final state — userDbName signals auth availability for UI guards
|
||||
setUserDbName(user?.id || null);
|
||||
setWorkerDbName(null);
|
||||
setIsInitialized(true);
|
||||
setIsLoading(false);
|
||||
initializationRef.current.isComplete = true;
|
||||
@ -294,13 +290,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
|
||||
// Calendar Navigation Functions
|
||||
const navigateToDay = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'day'
|
||||
}, userDbName, workerDbName);
|
||||
}, null, null);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -334,13 +330,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
const navigateToWeek = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'week'
|
||||
}, userDbName, workerDbName);
|
||||
}, null, null);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -374,13 +370,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
const navigateToMonth = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'month'
|
||||
}, userDbName, workerDbName);
|
||||
}, null, null);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -414,13 +410,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
const navigateToYear = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'year'
|
||||
}, userDbName, workerDbName);
|
||||
}, null, null);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -455,13 +451,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
|
||||
// Worker Navigation Functions
|
||||
const navigateToTimetable = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'timetable'
|
||||
}, userDbName, workerDbName);
|
||||
}, null, null);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -492,13 +488,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
const navigateToJournal = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'journal'
|
||||
}, userDbName, workerDbName);
|
||||
}, null, null);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -529,13 +525,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
const navigateToPlanner = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'planner'
|
||||
}, userDbName, workerDbName);
|
||||
}, null, null);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -566,14 +562,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
const navigateToClass = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'classes'
|
||||
}, userDbName, workerDbName);
|
||||
await navigationStore.navigate(id, userDbName);
|
||||
}, null, null);
|
||||
await navigationStore.navigate(id, '');
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
@ -604,14 +600,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
};
|
||||
|
||||
const navigateToLesson = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
if (!user?.id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'lessons'
|
||||
}, userDbName, workerDbName);
|
||||
await navigationStore.navigate(id, userDbName);
|
||||
}, null, null);
|
||||
await navigationStore.navigate(id, '');
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
|
||||
@ -10,11 +10,11 @@ import {
|
||||
TLStoreWithStatus
|
||||
} from '@tldraw/tldraw';
|
||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
// Tldraw services
|
||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
|
||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||
// Tldraw utils
|
||||
@ -46,6 +46,8 @@ interface LoadingState {
|
||||
export default function SinglePlayerPage() {
|
||||
// Context hooks with initialization states
|
||||
const { profile: user, loading: userLoading } = useUser();
|
||||
const { accessToken } = useAuth();
|
||||
const { context, setAuthInfo, switchContext } = useNavigationStore();
|
||||
const {
|
||||
tldrawPreferences,
|
||||
initializePreferences,
|
||||
@ -55,8 +57,6 @@ export default function SinglePlayerPage() {
|
||||
const routerNavigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Navigation store
|
||||
const { context } = useNavigationStore();
|
||||
|
||||
// Refs
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
@ -114,6 +114,7 @@ export default function SinglePlayerPage() {
|
||||
|
||||
// 2. Initialize snapshot service
|
||||
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
||||
if (accessToken) snapshotService.setAccessToken(accessToken);
|
||||
snapshotServiceRef.current = snapshotService;
|
||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
||||
|
||||
@ -131,12 +132,14 @@ export default function SinglePlayerPage() {
|
||||
|
||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||
nodeStoragePath,
|
||||
null,
|
||||
accessToken || '',
|
||||
newStore,
|
||||
setLoadingState,
|
||||
undefined, // sharedStore
|
||||
editorRef.current || undefined // editor
|
||||
undefined,
|
||||
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');
|
||||
} else {
|
||||
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;
|
||||
|
||||
newStore.listen(() => {
|
||||
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) {
|
||||
if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) {
|
||||
// Skip if already saving
|
||||
if (isAutoSaving) {
|
||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
|
||||
@ -178,8 +181,6 @@ export default function SinglePlayerPage() {
|
||||
isAutoSaving = false;
|
||||
}
|
||||
}, 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 {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
|
||||
// Center the node
|
||||
if (context.node.type !== 'workspace') {
|
||||
try {
|
||||
const nodeData = await loadNodeData(context.node);
|
||||
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
||||
} catch (shapeErr) {
|
||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: context.node.type, error: shapeErr });
|
||||
}
|
||||
}
|
||||
|
||||
setIsInitialLoad(false);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
@ -297,12 +303,17 @@ export default function SinglePlayerPage() {
|
||||
? context.history.nodes[context.history.currentIndex - 1]
|
||||
: null;
|
||||
|
||||
// Handle navigation in snapshot service
|
||||
// Handle navigation in snapshot service (load/save snapshot)
|
||||
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
||||
|
||||
// Center the node on canvas
|
||||
if (currentNode.type !== 'workspace') {
|
||||
try {
|
||||
const nodeData = await loadNodeData(currentNode);
|
||||
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
||||
} catch (shapeErr) {
|
||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: currentNode.type, error: shapeErr });
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
@ -315,7 +326,17 @@ export default function SinglePlayerPage() {
|
||||
};
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -462,9 +483,6 @@ export default function SinglePlayerPage() {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||
{(loadingState.status === 'loading' || !store) && (
|
||||
@ -527,6 +545,7 @@ export default function SinglePlayerPage() {
|
||||
// Update snapshot service with editor reference
|
||||
if (snapshotServiceRef.current) {
|
||||
snapshotServiceRef.current.setEditor(editor);
|
||||
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||
}
|
||||
|
||||
setIsEditorReady(true);
|
||||
@ -565,68 +584,21 @@ const getNodeStoragePath = (node: NavigationNode): string | null => {
|
||||
};
|
||||
|
||||
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
||||
// Validate the node parameter
|
||||
if (!node) {
|
||||
throw new Error('Node parameter is required');
|
||||
}
|
||||
|
||||
if (!node.id) {
|
||||
throw new Error('Node must have an ID');
|
||||
}
|
||||
|
||||
if (!node?.id) throw new Error('Node parameter is required');
|
||||
const nodeStoragePath = getNodeStoragePath(node);
|
||||
if (!nodeStoragePath) {
|
||||
throw new Error(`Node ${node.id} is missing node_storage_path`);
|
||||
}
|
||||
if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`);
|
||||
|
||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
nodeLabel: node.label,
|
||||
nodeStoragePath: nodeStoragePath
|
||||
});
|
||||
|
||||
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);
|
||||
return {
|
||||
...fetchedData.node_data,
|
||||
title: String(fetchedData.node_data.title || node.label || ''),
|
||||
title: node.label || node.type || '',
|
||||
w: 500,
|
||||
h: 350,
|
||||
state: {
|
||||
parentId: null,
|
||||
isPageChild: true,
|
||||
hasChildren: null,
|
||||
bindings: null
|
||||
},
|
||||
state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null },
|
||||
headerColor: theme.headerColor,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
isLocked: false,
|
||||
__primarylabel__: node.type,
|
||||
uuid_string: node.id,
|
||||
node_storage_path: nodeStoragePath
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,27 +1,52 @@
|
||||
// External imports
|
||||
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
||||
import axios from '../../axiosConfig';
|
||||
import logger from '../../debugConfig';
|
||||
import { SharedStoreService } from './sharedStoreService';
|
||||
import { StorageKeys, storageService } from '../auth/localStorageService';
|
||||
import { NavigationNode } from '../../types/navigation';
|
||||
|
||||
export interface LoadingState {
|
||||
status: 'loading' | 'ready' | 'error';
|
||||
error: string;
|
||||
}
|
||||
|
||||
const EMPTY_NODE: NavigationNode = {
|
||||
id: '',
|
||||
node_storage_path: '',
|
||||
type: '',
|
||||
label: ''
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
|
||||
const BUCKET = 'cc.users';
|
||||
|
||||
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 {
|
||||
private store: TLStore;
|
||||
private editor: Editor | null = null;
|
||||
private currentNodePath: string | null = null;
|
||||
private _accessToken: string | null = null;
|
||||
private isAutoSaveEnabled = true;
|
||||
private isSaving = false;
|
||||
private isLoading = false;
|
||||
@ -33,24 +58,21 @@ export class NavigationSnapshotService {
|
||||
this.editor = editor || null;
|
||||
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
|
||||
storeId: store.id,
|
||||
hasEditor: !!editor
|
||||
hasEditor: !!editor,
|
||||
});
|
||||
}
|
||||
|
||||
setEditor(editor: Editor): void {
|
||||
this.editor = editor;
|
||||
logger.debug('snapshot-service', '🔄 Editor reference updated', {
|
||||
editorId: editor.store.id
|
||||
});
|
||||
}
|
||||
|
||||
private static replaceBackslashes(input: string | undefined): string {
|
||||
return input ? input.replace(/\\/g, '/') : '';
|
||||
setAccessToken(token: string): void {
|
||||
this._accessToken = token;
|
||||
}
|
||||
|
||||
static async loadNodeSnapshotFromDatabase(
|
||||
nodePath: string,
|
||||
dbName: string,
|
||||
accessToken: string,
|
||||
store: TLStore,
|
||||
setLoadingState: (state: LoadingState) => void,
|
||||
sharedStore?: SharedStoreService,
|
||||
@ -58,252 +80,102 @@ export class NavigationSnapshotService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
|
||||
|
||||
logger.info('snapshot-service', '📂 Loading file from path', {
|
||||
path: nodePath,
|
||||
db_name: dbName
|
||||
});
|
||||
const snapshot = await storageGet(nodePath, accessToken);
|
||||
|
||||
const response = await axios.get(
|
||||
'/database/tldraw_supabase/get_tldraw_node_file', {
|
||||
params: {
|
||||
path: this.replaceBackslashes(nodePath),
|
||||
db_name: dbName
|
||||
if (!snapshot) {
|
||||
logger.debug('snapshot-service', 'ℹ️ No snapshot found at path — clearing canvas', { nodePath });
|
||||
// Clear all shapes so the canvas is blank for this new node
|
||||
if (editor) {
|
||||
const shapeIds = [...editor.getCurrentPageShapeIds()];
|
||||
if (shapeIds.length > 0) {
|
||||
editor.deleteShapes(shapeIds);
|
||||
}
|
||||
}
|
||||
);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = response.data;
|
||||
logger.debug('snapshot-service', '🔍 Snapshot data received', {
|
||||
hasSnapshot: !!snapshot,
|
||||
hasDocument: !!snapshot?.document,
|
||||
hasSession: !!snapshot?.session,
|
||||
hasSchemaVersion: !!snapshot?.schemaVersion,
|
||||
schemaVersion: snapshot?.schemaVersion,
|
||||
snapshotKeys: snapshot ? Object.keys(snapshot) : []
|
||||
});
|
||||
|
||||
if (snapshot && snapshot.document && snapshot.session) {
|
||||
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
|
||||
const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown };
|
||||
if (!snap.document || !snap.session) {
|
||||
logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath });
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sharedStore) {
|
||||
await sharedStore.loadSnapshot(snapshot, setLoadingState);
|
||||
} else {
|
||||
logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', {
|
||||
hasStore: !!store,
|
||||
snapshotType: typeof snapshot,
|
||||
snapshotKeys: Object.keys(snapshot),
|
||||
snapshotSchemaVersion: snapshot?.schemaVersion,
|
||||
snapshotDocument: !!snapshot?.document,
|
||||
snapshotSession: !!snapshot?.session
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a defensive copy to ensure the snapshot doesn't get modified
|
||||
const snapshotCopy = {
|
||||
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion,
|
||||
document: snapshot.document,
|
||||
session: snapshot.session
|
||||
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
||||
document: snap.document,
|
||||
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 {
|
||||
// Ensure store is properly initialized before loading snapshot
|
||||
if (!store) {
|
||||
throw new Error('Store is null or undefined');
|
||||
}
|
||||
|
||||
// Validate snapshot structure before loading
|
||||
if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) {
|
||||
throw new Error('Invalid snapshot structure');
|
||||
}
|
||||
|
||||
// Check for schema migrations and handle them properly
|
||||
logger.debug('snapshot-service', '🔄 Checking for schema migrations', {
|
||||
storeId: store.id,
|
||||
storeType: typeof store,
|
||||
storeConstructor: store.constructor.name,
|
||||
snapshotSchemaVersion: snapshotCopy.schemaVersion,
|
||||
snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}),
|
||||
snapshotSessionKeys: Object.keys(snapshotCopy.session || {})
|
||||
});
|
||||
|
||||
try {
|
||||
// Try to load the snapshot directly first
|
||||
logger.debug('snapshot-service', '🔄 Attempting to load snapshot directly');
|
||||
if (editor) {
|
||||
loadSnapshot(editor.store, snapshotCopy);
|
||||
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||
} else {
|
||||
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||
}
|
||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const isSchemaMigration = /migration|schema|Incompatible/i.test(msg);
|
||||
if (isSchemaMigration) {
|
||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
||||
} else {
|
||||
// Fallback: use global loadSnapshot if no editor available
|
||||
logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot');
|
||||
loadSnapshot(store, snapshotCopy);
|
||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully via global loadSnapshot');
|
||||
}
|
||||
} catch (migrationError) {
|
||||
// Check if this is a schema migration error that we can safely ignore
|
||||
const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError);
|
||||
const isSchemaMigrationError = errorMessage.includes('migration') ||
|
||||
errorMessage.includes('schema') ||
|
||||
errorMessage.includes('Incompatible');
|
||||
|
||||
if (isSchemaMigrationError) {
|
||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', {
|
||||
error: errorMessage
|
||||
});
|
||||
// Continue with empty store - this is expected for some snapshots
|
||||
} else {
|
||||
logger.warn('snapshot-service', '⚠️ Unexpected load error', {
|
||||
error: errorMessage
|
||||
});
|
||||
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('snapshot-service', '✅ loadSnapshot call succeeded');
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (loadError) {
|
||||
logger.error('snapshot-service', '❌ loadSnapshot call failed', {
|
||||
error: loadError instanceof Error ? loadError.message : String(loadError),
|
||||
storeType: typeof store,
|
||||
storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function',
|
||||
snapshotType: typeof snapshotCopy,
|
||||
snapshotKeys: Object.keys(snapshotCopy)
|
||||
});
|
||||
throw loadError;
|
||||
}
|
||||
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
|
||||
}
|
||||
} else {
|
||||
logger.error('snapshot-service', '❌ Invalid snapshot format');
|
||||
setLoadingState({ status: 'error', error: 'Invalid snapshot format' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('snapshot-service', '❌ Failed to load snapshot', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
setLoadingState({
|
||||
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(
|
||||
nodePath: string,
|
||||
dbName: string,
|
||||
accessToken: string,
|
||||
store: TLStore
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info('snapshot-service', '💾 Saving snapshot to database', {
|
||||
path: nodePath,
|
||||
db_name: dbName
|
||||
});
|
||||
|
||||
logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
|
||||
const snapshot = getSnapshot(store);
|
||||
|
||||
// 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') {
|
||||
await storagePut(nodePath, accessToken, snapshot);
|
||||
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
|
||||
} else {
|
||||
throw new Error('Failed to save snapshot');
|
||||
}
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
|
||||
if (!this.currentNodePath || this.currentNodePath !== nodePath) {
|
||||
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
|
||||
currentPath: this.currentNodePath,
|
||||
savePath: nodePath
|
||||
});
|
||||
if (!this.currentNodePath || this.currentNodePath !== nodePath) return;
|
||||
if (!this._accessToken) {
|
||||
logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isSaving = true;
|
||||
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 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
|
||||
});
|
||||
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store);
|
||||
logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath });
|
||||
} catch (error) {
|
||||
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
nodePath
|
||||
nodePath,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
@ -311,141 +183,77 @@ export class NavigationSnapshotService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSnapshotForNode(node: NavigationNode): Promise<void> {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
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)');
|
||||
private async loadSnapshotForNode(node: { node_storage_path: string }): Promise<void> {
|
||||
if (!this._accessToken) {
|
||||
logger.debug('snapshot-service', '⚠️ No access token — snapshot load skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('snapshot-service', '📥 Loading snapshot', {
|
||||
nodePath: node.node_storage_path,
|
||||
dbName,
|
||||
userType: user.user_type,
|
||||
username: user.username
|
||||
});
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||
node.node_storage_path,
|
||||
dbName,
|
||||
this._accessToken,
|
||||
this.store,
|
||||
(state: LoadingState) => {
|
||||
if (state.status === 'ready') {
|
||||
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
|
||||
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot
|
||||
undefined,
|
||||
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 {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
|
||||
if (!toNode) {
|
||||
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending debounce
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
|
||||
// Debounce the navigation operation
|
||||
async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> {
|
||||
if (!toNode) return;
|
||||
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
|
||||
return new Promise((resolve) => {
|
||||
this.debounceTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await this.executeNavigation(fromNode || EMPTY_NODE, toNode);
|
||||
await this.executeNavigation(fromNode, toNode);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
logger.error('snapshot-service', '❌ Navigation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}, 100); // 100ms debounce
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): 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
|
||||
private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> {
|
||||
if (this.isSaving || this.isLoading) {
|
||||
this.pendingOperation = {
|
||||
save: fromNode.node_storage_path || undefined,
|
||||
load: toNode.node_storage_path
|
||||
save: fromNode?.node_storage_path,
|
||||
load: toNode.node_storage_path,
|
||||
};
|
||||
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the store before loading new snapshot
|
||||
logger.debug('snapshot-service', '🔄 Clearing store');
|
||||
this.currentNodePath = null;
|
||||
logger.debug('snapshot-service', '🧹 Cleared current node path');
|
||||
|
||||
// Load the new node's snapshot
|
||||
if (toNode.node_storage_path) {
|
||||
await this.loadSnapshotForNode(toNode);
|
||||
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
|
||||
nodePath: toNode.node_storage_path
|
||||
});
|
||||
}
|
||||
|
||||
// Process any pending operations
|
||||
if (this.pendingOperation) {
|
||||
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation);
|
||||
const operation = this.pendingOperation;
|
||||
const op = this.pendingOperation;
|
||||
this.pendingOperation = null;
|
||||
await this.handleNavigationStart(
|
||||
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null,
|
||||
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null
|
||||
op.save ? { node_storage_path: op.save } : 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 {
|
||||
this.isAutoSaveEnabled = enabled;
|
||||
logger.debug('snapshot-service', '🔄 Auto-save setting changed', {
|
||||
enabled
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentNodePath(path: string): void {
|
||||
this.currentNodePath = path;
|
||||
}
|
||||
|
||||
getCurrentNodePath(): string | null {
|
||||
@ -455,14 +263,11 @@ export class NavigationSnapshotService {
|
||||
async forceSaveCurrentNode(): Promise<void> {
|
||||
if (this.currentNodePath) {
|
||||
await this.saveCurrentSnapshot(this.currentNodePath);
|
||||
} else {
|
||||
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentNode(): void {
|
||||
this.currentNodePath = null;
|
||||
this.store.clear();
|
||||
logger.debug('snapshot-service', '🧹 Cleared current node and store');
|
||||
}
|
||||
}
|
||||
@ -1,32 +1,45 @@
|
||||
import { create } from 'zustand';
|
||||
import { UserNeoDBService } from '../services/graph/userNeoDBService';
|
||||
import { logger } from '../debugConfig';
|
||||
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import {
|
||||
NavigationStore,
|
||||
NavigationNode,
|
||||
NeoGraphNode,
|
||||
MainContext,
|
||||
BaseContext,
|
||||
NavigationContextState,
|
||||
isProfileContext,
|
||||
isInstituteContext,
|
||||
getContextDatabase,
|
||||
addToHistory,
|
||||
navigateHistory,
|
||||
getCurrentHistoryNode,
|
||||
ExtendedContext,
|
||||
UnifiedContextSwitch,
|
||||
NodeContext
|
||||
} 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 = {
|
||||
main: 'profile',
|
||||
base: 'profile',
|
||||
node: null,
|
||||
history: {
|
||||
nodes: [],
|
||||
currentIndex: -1
|
||||
}
|
||||
history: { nodes: [], currentIndex: -1 }
|
||||
};
|
||||
|
||||
function getDefaultBaseForMain(main: MainContext): BaseContext {
|
||||
@ -38,210 +51,140 @@ function validateContextTransition(
|
||||
updates: Partial<NavigationContextState>
|
||||
): NavigationContextState {
|
||||
const newState = { ...current, ...updates };
|
||||
|
||||
// Validate main context
|
||||
if (updates.main) {
|
||||
newState.base = getDefaultBaseForMain(updates.main);
|
||||
}
|
||||
|
||||
// Validate base context
|
||||
if (updates.base) {
|
||||
// Ensure base context matches main context
|
||||
const isValid = newState.main === 'profile'
|
||||
? isProfileContext(updates.base)
|
||||
: isInstituteContext(updates.base);
|
||||
|
||||
if (!isValid) {
|
||||
newState.base = getDefaultBaseForMain(newState.main);
|
||||
}
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
export interface NavigationActions {
|
||||
// Context Navigation
|
||||
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||
export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
|
||||
const pgFetch = async <T = unknown>(
|
||||
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>;
|
||||
};
|
||||
|
||||
// Node Navigation
|
||||
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
||||
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||
const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
|
||||
const userId = get()._userId;
|
||||
if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
|
||||
|
||||
// History Navigation
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||
query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`,
|
||||
});
|
||||
|
||||
// Utility Methods
|
||||
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||
if (rooms && rooms.length > 0) {
|
||||
const room = rooms[0];
|
||||
return {
|
||||
id: room.id,
|
||||
node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`,
|
||||
label: room.name,
|
||||
type: 'workspace',
|
||||
};
|
||||
}
|
||||
|
||||
export interface NavigationState {
|
||||
context: {
|
||||
main: NodeContext;
|
||||
base: NodeContext;
|
||||
extended?: string;
|
||||
node: NavigationNode;
|
||||
history: {
|
||||
nodes: NavigationNode[];
|
||||
currentIndex: number;
|
||||
};
|
||||
};
|
||||
// ... rest of the state interface ...
|
||||
}
|
||||
const storagePath = `${userId}/workspaces/${contextType}_default.json`;
|
||||
const room = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
||||
body: {
|
||||
user_id: userId,
|
||||
name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`,
|
||||
context_type: contextType,
|
||||
is_default: true,
|
||||
storage_path: storagePath,
|
||||
},
|
||||
prefer: 'return=representation',
|
||||
single: true,
|
||||
});
|
||||
|
||||
if (!room) throw new Error('Failed to create default whiteboard room');
|
||||
logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id });
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
node_storage_path: room.storage_path || storagePath,
|
||||
label: room.name,
|
||||
type: 'workspace',
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
_accessToken: null,
|
||||
_userId: null,
|
||||
setAuthInfo: (token: string | null, userId: string | null) => {
|
||||
set({ _accessToken: token, _userId: userId });
|
||||
},
|
||||
|
||||
export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
||||
context: initialState,
|
||||
isLoading: false,
|
||||
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 {
|
||||
// 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 });
|
||||
|
||||
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) {
|
||||
newState = validateContextTransition(newState, { main: contextSwitch.main });
|
||||
if (!contextSwitch.skipBaseContextLoad) {
|
||||
newState.base = getDefaultBaseForMain(contextSwitch.main);
|
||||
}
|
||||
logger.debug('navigation-state', '✅ Main context updated', {
|
||||
previous: currentState.main,
|
||||
new: newState.main,
|
||||
defaultBase: newState.base
|
||||
});
|
||||
}
|
||||
|
||||
// Update base context if provided
|
||||
if (contextSwitch.base) {
|
||||
newState = validateContextTransition(newState, { base: contextSwitch.base });
|
||||
logger.debug('navigation-state', '✅ Base context updated', {
|
||||
previous: currentState.base,
|
||||
new: newState.base
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('navigation-state', '✅ Context validation complete', {
|
||||
validatedState: newState,
|
||||
originalState: currentState
|
||||
});
|
||||
|
||||
// Determine which context to use for the node
|
||||
const targetContext = contextSwitch.base ||
|
||||
contextSwitch.extended ||
|
||||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) :
|
||||
newState.base);
|
||||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
|
||||
|
||||
// Get database name
|
||||
const dbName = getContextDatabase(newState, userDbName, workerDbName);
|
||||
|
||||
logger.debug('context-switch', '🔍 Fetching default node for context', {
|
||||
targetContext,
|
||||
dbName,
|
||||
currentState: newState
|
||||
});
|
||||
|
||||
// Get default node for the final context
|
||||
const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName);
|
||||
|
||||
if (!defaultNode) {
|
||||
const errorMsg = `No default node found for context: ${targetContext}`;
|
||||
logger.error('context-switch', '❌ Default node fetch failed', { targetContext });
|
||||
set({
|
||||
error: errorMsg,
|
||||
isLoading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('context-switch', '✨ Default node fetched', {
|
||||
nodeId: defaultNode.id,
|
||||
node_storage_path: defaultNode.node_storage_path,
|
||||
type: defaultNode.type
|
||||
});
|
||||
|
||||
// Update history and state
|
||||
const defaultNode = await getOrCreateDefaultRoom(targetContext);
|
||||
const newHistory = addToHistory(currentState.history, defaultNode);
|
||||
logger.debug('history-management', '📚 History updated', {
|
||||
previousState: currentState.history,
|
||||
newState: newHistory,
|
||||
addedNode: defaultNode
|
||||
});
|
||||
|
||||
set({
|
||||
context: {
|
||||
...newState,
|
||||
node: defaultNode,
|
||||
history: newHistory
|
||||
},
|
||||
context: { ...newState, node: defaultNode, history: newHistory },
|
||||
isLoading: false,
|
||||
error: null
|
||||
error: null,
|
||||
});
|
||||
|
||||
logger.debug('navigation-context', '✅ Context switch completed', {
|
||||
finalState: {
|
||||
main: newState.main,
|
||||
base: newState.base,
|
||||
nodeId: defaultNode.id
|
||||
}
|
||||
logger.debug('navigation-context', '✅ Context switch complete', {
|
||||
main: newState.main, base: newState.base, nodeId: defaultNode.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('navigation-context', '❌ Failed to switch context:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to switch context',
|
||||
isLoading: false
|
||||
});
|
||||
logger.error('navigation-context', '❌ switchContext failed', error);
|
||||
set({ 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;
|
||||
if (currentState.history.currentIndex > 0) {
|
||||
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
|
||||
const node = getCurrentHistoryNode(newHistory);
|
||||
set({
|
||||
context: {
|
||||
...currentState,
|
||||
node,
|
||||
history: newHistory
|
||||
}
|
||||
});
|
||||
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
|
||||
}
|
||||
},
|
||||
|
||||
@ -264,176 +200,139 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
||||
const currentState = get().context;
|
||||
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
|
||||
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
|
||||
const node = getCurrentHistoryNode(newHistory);
|
||||
set({
|
||||
context: {
|
||||
...currentState,
|
||||
node,
|
||||
history: newHistory
|
||||
}
|
||||
});
|
||||
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
|
||||
}
|
||||
},
|
||||
|
||||
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
|
||||
try {
|
||||
// Use switchContext instead of direct implementation
|
||||
await get().switchContext({ main }, userDbName, workerDbName);
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to set main context:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to set main context',
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
|
||||
try {
|
||||
// Use switchContext instead of direct implementation
|
||||
await get().switchContext({ base }, userDbName, workerDbName);
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to set base context:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to set base context',
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
|
||||
try {
|
||||
// Use switchContext instead of direct implementation
|
||||
await get().switchContext({ extended }, userDbName, workerDbName);
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to set extended context:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to set extended context',
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
navigate: async (nodeId: string, dbName: string) => {
|
||||
navigate: async (nodeId: string, _dbName: string) => {
|
||||
try {
|
||||
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 existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
|
||||
|
||||
// If node exists in history, just navigate to it
|
||||
if (existingNodeIndex !== -1) {
|
||||
logger.debug('navigation', '📍 Navigating to existing node in history', {
|
||||
nodeId,
|
||||
historyIndex: existingNodeIndex,
|
||||
currentIndex: currentState.history.currentIndex
|
||||
});
|
||||
|
||||
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
|
||||
const node = getCurrentHistoryNode(newHistory);
|
||||
|
||||
set({
|
||||
context: {
|
||||
...currentState,
|
||||
node,
|
||||
history: newHistory
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
|
||||
if (existingIndex !== -1) {
|
||||
const newHistory = navigateHistory(currentState.history, existingIndex);
|
||||
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch new node data
|
||||
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName);
|
||||
if (!nodeData) {
|
||||
throw new Error(`Node not found: ${nodeId}`);
|
||||
}
|
||||
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||
query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`,
|
||||
});
|
||||
if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`);
|
||||
|
||||
const room = rooms[0];
|
||||
const node: NavigationNode = {
|
||||
id: nodeId,
|
||||
node_storage_path: nodeData.node_data.node_storage_path || '',
|
||||
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
|
||||
type: nodeData.node_type
|
||||
id: room.id,
|
||||
node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`,
|
||||
label: room.name,
|
||||
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);
|
||||
set({
|
||||
context: {
|
||||
...currentState,
|
||||
node,
|
||||
history: newHistory
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
set({ context: { ...currentState, node, history: newHistory }, isLoading: false });
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to navigate:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to navigate',
|
||||
isLoading: false
|
||||
});
|
||||
logger.error('navigation', '❌ navigate failed', error);
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
|
||||
if (!isValidNodeType(node.type)) {
|
||||
logger.warn('navigation', `⚠️ navigateToNode called with non-graph type: ${node.type} — navigating anyway`);
|
||||
}
|
||||
await get().navigate(node.id, userDbName || '');
|
||||
},
|
||||
|
||||
refreshNavigationState: async (_userDbName: string | null, _workerDbName: string | null) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
if (!isValidNodeType(node.type)) {
|
||||
throw new Error(`Invalid node type: ${node.type}`);
|
||||
}
|
||||
|
||||
const dbName = getContextDatabase(get().context, userDbName, workerDbName);
|
||||
await get().navigate(node.id, dbName);
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to navigate to node:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to navigate to node',
|
||||
isLoading: false
|
||||
const currentNode = get().context.node;
|
||||
if (currentNode && get()._accessToken) {
|
||||
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||
query: `id=eq.${currentNode.id}`,
|
||||
});
|
||||
if (rooms && rooms.length > 0) {
|
||||
const room = rooms[0];
|
||||
set({
|
||||
context: {
|
||||
...get().context,
|
||||
node: {
|
||||
id: room.id,
|
||||
node_storage_path: room.storage_path || currentNode.node_storage_path,
|
||||
label: room.name,
|
||||
type: 'workspace',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ refreshNavigationState failed', error);
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
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 {
|
||||
set({ isLoading: true, error: null });
|
||||
const currentState = get().context;
|
||||
|
||||
if (currentState.node) {
|
||||
const dbName = getContextDatabase(currentState, userDbName, workerDbName);
|
||||
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName);
|
||||
if (nodeData) {
|
||||
const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`,
|
||||
});
|
||||
let room: WhiteboardRoom;
|
||||
if (existing && existing.length > 0) {
|
||||
room = existing[0];
|
||||
} else {
|
||||
const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`;
|
||||
const created = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
||||
body: {
|
||||
user_id: userId,
|
||||
name: neoNode.label,
|
||||
context_type: neoNode.node_type.toLowerCase(),
|
||||
is_default: false,
|
||||
storage_path: storagePath,
|
||||
neo4j_node_id: neoNode.neo4j_node_id,
|
||||
neo4j_db_name: neoNode.neo4j_db_name,
|
||||
node_type: neoNode.node_type,
|
||||
},
|
||||
prefer: 'return=representation',
|
||||
single: true,
|
||||
});
|
||||
if (!created) throw new Error('Failed to create whiteboard room for node');
|
||||
room = created;
|
||||
}
|
||||
const node: NavigationNode = {
|
||||
id: currentState.node.id,
|
||||
node_storage_path: nodeData.node_data.node_storage_path || '',
|
||||
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
|
||||
type: nodeData.node_type
|
||||
id: room.id,
|
||||
node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`,
|
||||
label: room.name,
|
||||
type: neoNode.node_type,
|
||||
};
|
||||
set({
|
||||
context: {
|
||||
...currentState,
|
||||
node
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: false });
|
||||
const currentState = get().context;
|
||||
const newHistory = addToHistory(currentState.history, node);
|
||||
set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null });
|
||||
logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode });
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to refresh navigation state:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh navigation state',
|
||||
isLoading: false
|
||||
logger.error('navigation', '❌ navigateToNeoNode failed', error);
|
||||
set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
@ -1,5 +1,4 @@
|
||||
import { create } from 'zustand';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
export interface TranscriptionSegment {
|
||||
text: string;
|
||||
@ -168,7 +167,37 @@ interface TranscriptionState {
|
||||
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,
|
||||
isConnecting: false,
|
||||
activeSession: null,
|
||||
@ -209,10 +238,9 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
startSession: async (timetableTag?: TimetablePeriod) => {
|
||||
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
|
||||
|
||||
// Create session in Supabase
|
||||
try {
|
||||
const { _accessToken: token, _userId: userId } = get();
|
||||
if (!token || !userId) {
|
||||
const { _userId: userId } = get();
|
||||
if (!userId) {
|
||||
console.error('No authenticated user');
|
||||
return;
|
||||
}
|
||||
@ -227,14 +255,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
auto_tagged: !!timetableTag,
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transcription_sessions')
|
||||
.insert(sessionData)
|
||||
.select()
|
||||
.single();
|
||||
const data = await pgFetch<TranscriptionSession>('POST', 'transcription_sessions', {
|
||||
body: sessionData,
|
||||
prefer: 'return=representation',
|
||||
single: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
if (!data) {
|
||||
console.error('Failed to create session: no data returned');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -255,14 +283,16 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
const idx = newCompleted.length;
|
||||
newCompleted.push({ ...currentSegment, isFinal: true });
|
||||
if (activeSession) {
|
||||
supabase.from('transcription_segments').insert({
|
||||
pgFetch('POST', 'transcription_segments', {
|
||||
body: {
|
||||
session_id: activeSession.id,
|
||||
sequence_index: idx,
|
||||
text: currentSegment.text,
|
||||
start_seconds: currentSegment.start,
|
||||
end_seconds: currentSegment.end,
|
||||
is_final: true,
|
||||
}).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) {
|
||||
try {
|
||||
await supabase
|
||||
.from('transcription_sessions')
|
||||
.update({
|
||||
await pgFetch('PATCH', 'transcription_sessions', {
|
||||
query: `id=eq.${activeSession.id}`,
|
||||
body: {
|
||||
ended_at: new Date().toISOString(),
|
||||
word_count: finalWordCount,
|
||||
segment_count: newCompleted.length,
|
||||
})
|
||||
.eq('id', activeSession.id);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to end session:', error);
|
||||
}
|
||||
@ -339,14 +369,16 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
const elapsed = get().elapsedSeconds;
|
||||
for (const { seg, idx } of toSave) {
|
||||
if (activeSession) {
|
||||
supabase.from('transcription_segments').insert({
|
||||
pgFetch('POST', 'transcription_segments', {
|
||||
body: {
|
||||
session_id: activeSession.id,
|
||||
sequence_index: idx,
|
||||
text: seg.text,
|
||||
start_seconds: seg.start,
|
||||
end_seconds: seg.end,
|
||||
is_final: true,
|
||||
}).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);
|
||||
}
|
||||
@ -402,13 +434,15 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
|
||||
if (isNew && activeSession) {
|
||||
try {
|
||||
await supabase.from('transcription_segments').insert({
|
||||
await pgFetch('POST', 'transcription_segments', {
|
||||
body: {
|
||||
session_id: activeSession.id,
|
||||
sequence_index: newCompleted.length - 1,
|
||||
text,
|
||||
start_seconds: metadata.start,
|
||||
end_seconds: metadata.end,
|
||||
is_final: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save segment:', error);
|
||||
@ -425,14 +459,16 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
);
|
||||
set({ completedSegments: autoCompleted, wordCount: autoWordCount });
|
||||
if (activeSession) {
|
||||
supabase.from('transcription_segments').insert({
|
||||
pgFetch('POST', 'transcription_segments', {
|
||||
body: {
|
||||
session_id: activeSession.id,
|
||||
sequence_index: autoCompleted.length - 1,
|
||||
text: currentSegment.text,
|
||||
start_seconds: currentSegment.start,
|
||||
end_seconds: currentSegment.end,
|
||||
is_final: true,
|
||||
}).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 } });
|
||||
@ -474,7 +510,8 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
|
||||
try {
|
||||
for (const event of eventsToFlush) {
|
||||
await supabase.from('canvas_events').insert({
|
||||
await pgFetch('POST', 'canvas_events', {
|
||||
body: {
|
||||
session_id: activeSession?.id || null,
|
||||
user_id: get()._userId || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -484,6 +521,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
canvas_snapshot_url: event.snapshotUrl || null,
|
||||
tldraw_page_id: event.pageId || null,
|
||||
tldraw_shape_ids: event.shapeIds || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -498,17 +536,9 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
const { _userId: userId } = get();
|
||||
if (!userId) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transcription_sessions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('started_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
return [];
|
||||
}
|
||||
const data = await pgFetch<TranscriptionSession[]>('GET', 'transcription_sessions', {
|
||||
query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`,
|
||||
});
|
||||
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
@ -678,13 +708,13 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
|
||||
if (activeSession) {
|
||||
try {
|
||||
const { _accessToken: _kwToken } = get();
|
||||
const { _accessToken: kwToken } = get();
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(_kwToken ? { 'Authorization': `Bearer ${_kwToken}` } : {}),
|
||||
...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: activeSession.id,
|
||||
@ -709,4 +739,5 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
clearKeywordMatches: () => {
|
||||
set({ keywordMatches: [] });
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
@ -227,6 +227,13 @@ export interface UnifiedContextSwitch {
|
||||
}
|
||||
|
||||
// Navigation Actions Interface
|
||||
export interface NeoGraphNode {
|
||||
neo4j_node_id: string;
|
||||
neo4j_db_name: string;
|
||||
node_type: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface NavigationActions {
|
||||
// Unified Context Switch
|
||||
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||
@ -239,6 +246,7 @@ export interface NavigationActions {
|
||||
// Node Navigation
|
||||
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
||||
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||
navigateToNeoNode: (neoNode: NeoGraphNode) => Promise<void>;
|
||||
|
||||
// History Navigation
|
||||
goBack: () => void;
|
||||
|
||||
@ -7,6 +7,14 @@ export interface TranscriptionConfig {
|
||||
useVad?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type ServerSegmentsCallback = (segments: ServerSegment[], isLastLive: boolean) => void;
|
||||
|
||||
export class TranscriptionService {
|
||||
private socket: WebSocket | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
@ -14,27 +22,29 @@ export class TranscriptionService {
|
||||
private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||||
private workletNode: AudioWorkletNode | null = null;
|
||||
private selectedDeviceId: string = '';
|
||||
private finalizedSegmentCount: number = 0;
|
||||
private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null;
|
||||
private intentionalStop: boolean = false;
|
||||
private onServerSegments: ServerSegmentsCallback | null = null;
|
||||
private onDisconnect: (() => void) | null = null;
|
||||
|
||||
constructor(deviceId: string = '') {
|
||||
this.selectedDeviceId = deviceId;
|
||||
}
|
||||
|
||||
setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) {
|
||||
this.onTranscriptionUpdate = callback;
|
||||
setServerSegmentsCallback(callback: ServerSegmentsCallback) {
|
||||
this.onServerSegments = callback;
|
||||
}
|
||||
|
||||
setDisconnectCallback(callback: () => void) {
|
||||
this.onDisconnect = callback;
|
||||
}
|
||||
|
||||
async startTranscription(config: TranscriptionConfig = {}) {
|
||||
console.log('🎙️ Starting transcription service...');
|
||||
this.intentionalStop = false;
|
||||
|
||||
try {
|
||||
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
|
||||
? { deviceId: { exact: this.selectedDeviceId } }
|
||||
: { echoCancellation: true, noiseSuppression: true };
|
||||
@ -60,13 +70,13 @@ export class TranscriptionService {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.info('transcription-service', '✅ WebSocket connected');
|
||||
|
||||
// Send initial configuration — audio capture starts only after SERVER_READY.
|
||||
ws.send(JSON.stringify({
|
||||
uid: uuid,
|
||||
language: config.language || 'en',
|
||||
task: config.task || 'transcribe',
|
||||
model: config.modelSize || 'base',
|
||||
model: config.modelSize || 'large-v3',
|
||||
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 = () => {
|
||||
logger.info('transcription-service', '🔌 WebSocket closed');
|
||||
const wasIntentional = this.intentionalStop;
|
||||
this.cleanup();
|
||||
if (!wasIntentional && this.onDisconnect) {
|
||||
this.onDisconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.uid !== uuid) {
|
||||
return;
|
||||
}
|
||||
if (data.uid !== uuid) return;
|
||||
|
||||
if (data.message === 'SERVER_READY') {
|
||||
// Server is ready — now safe to start streaming audio.
|
||||
logger.info('transcription-service', '🟢 Server ready, starting audio capture');
|
||||
this.setupAudioProcessing();
|
||||
return;
|
||||
@ -94,37 +105,29 @@ export class TranscriptionService {
|
||||
|
||||
if (data.status === 'WAIT') {
|
||||
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
|
||||
this.intentionalStop = true;
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message === 'DISCONNECT') {
|
||||
logger.info('transcription-service', '🔕 Server requested disconnection');
|
||||
this.intentionalStop = true;
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) {
|
||||
const segments = data.segments;
|
||||
const lastIdx = segments.length - 1;
|
||||
|
||||
// Only emit segments we have not finalized yet — avoids re-processing the
|
||||
// full array on every message (which caused the stuck last segment bug).
|
||||
for (let i = this.finalizedSegmentCount; i < lastIdx; i++) {
|
||||
const seg = segments[i];
|
||||
this.onTranscriptionUpdate(seg.text, true, {
|
||||
start: parseFloat(seg.start),
|
||||
end: parseFloat(seg.end),
|
||||
});
|
||||
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),
|
||||
});
|
||||
// Pass the full segment window directly to the store — the store owns
|
||||
// all boundary and archival decisions, matching the WhisperLive reference
|
||||
// frontend which simply re-renders the server's authoritative segment list.
|
||||
if (this.onServerSegments && data.segments && data.segments.length > 0) {
|
||||
const segs: ServerSegment[] = data.segments.map((s: any) => ({
|
||||
text: String(s.text ?? ''),
|
||||
start: parseFloat(s.start ?? 0),
|
||||
end: parseFloat(s.end ?? 0),
|
||||
}));
|
||||
const isLastLive = !(data.segments[data.segments.length - 1]?.completed);
|
||||
this.onServerSegments(segs, isLastLive);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@ -134,26 +137,18 @@ export class TranscriptionService {
|
||||
}
|
||||
|
||||
private async setupAudioProcessing() {
|
||||
if (!this.stream || !this.socket) {
|
||||
return;
|
||||
}
|
||||
if (!this.stream || !this.socket) return;
|
||||
|
||||
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 });
|
||||
|
||||
await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
|
||||
|
||||
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
|
||||
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) => {
|
||||
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() {
|
||||
// Signal the server cleanly so it can finalise the last segment.
|
||||
this.intentionalStop = true;
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send('END_OF_AUDIO');
|
||||
}
|
||||
@ -173,27 +168,22 @@ export class TranscriptionService {
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.finalizedSegmentCount = 0;
|
||||
if (this.workletNode) {
|
||||
this.workletNode.disconnect();
|
||||
this.workletNode = null;
|
||||
}
|
||||
|
||||
if (this.mediaStreamSource) {
|
||||
this.mediaStreamSource.disconnect();
|
||||
this.mediaStreamSource = null;
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
|
||||
@ -1,15 +1,49 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Close from '@mui/icons-material/Close';
|
||||
import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'ollama', label: 'Ollama' },
|
||||
{ value: 'ollama', label: 'Ollama (local)' },
|
||||
{ value: 'openrouter', label: 'OpenRouter' },
|
||||
{ value: 'google', label: 'Google' },
|
||||
{ value: 'google', label: 'Google Gemini' },
|
||||
] 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 { llmConfig, setLLMConfig } = useTranscriptionStore();
|
||||
const [form, setForm] = useState<LLMConfig>(llmConfig);
|
||||
@ -25,97 +59,196 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is
|
||||
const handleSave = () => {
|
||||
setLLMConfig(form);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
onClose();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
style={{
|
||||
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 */}
|
||||
<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 */}
|
||||
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
LLM Provider Settings
|
||||
</h3>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid var(--color-divider)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-5 sm:p-6 space-y-4">
|
||||
{/* Provider dropdown */}
|
||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
|
||||
{/* ── Transcription section ── */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<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)',
|
||||
}}>
|
||||
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
|
||||
value={form.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) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Model
|
||||
</label>
|
||||
<label style={labelStyle}>Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.model}
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
||||
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"
|
||||
placeholder={
|
||||
form.provider === 'ollama' ? 'e.g. gemma4:e4b, llama3.2' :
|
||||
form.provider === 'anthropic' ? 'e.g. claude-sonnet-4-6' :
|
||||
form.provider === 'google' ? 'e.g. gemini-2.0-flash' :
|
||||
'e.g. gpt-4o, gpt-4o-mini'
|
||||
}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
{form.provider === 'ollama' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
<label style={labelStyle}>Ollama Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.baseUrl || ''}
|
||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||
placeholder="https://ollama.kevlarai.com"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
{form.provider === 'ollama' ? 'API Key (optional — leave blank if unrestricted)' : 'API Key'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
||||
placeholder="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"
|
||||
placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : 'sk-...'}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p className="text-xs text-gray-500">
|
||||
API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server.
|
||||
</p>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '8px' }}>
|
||||
API keys are stored in your browser only.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
|
||||
saved
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
style={{
|
||||
padding: '9px',
|
||||
border: 'none',
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user