diff --git a/src/components/navigation/GraphNavigator.tsx b/src/components/navigation/GraphNavigator.tsx index 353a1ff..2054946 100644 --- a/src/components/navigation/GraphNavigator.tsx +++ b/src/components/navigation/GraphNavigator.tsx @@ -1,458 +1,121 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react'; -import { - IconButton, - Tooltip, - Box, - Menu, - MenuItem, - ListItemIcon, - ListItemText, - Button, - styled +import React, { useState } from 'react'; +import { + IconButton, Tooltip, Box, Menu, MenuItem, + ListItemIcon, ListItemText, Chip, styled, } from '@mui/material'; -import { +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 ; + case 'CalendarYear': return ; + case 'CalendarMonth': return ; + case 'CalendarDay': return ; + default: return ; } -})); - -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); + const { context, goBack, goForward, isLoading } = useNavigationStore(); const [historyMenuAnchor, setHistoryMenuAnchor] = useState(null); - const rootRef = useRef(null); - const [availableWidth, setAvailableWidth] = useState(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) => { - 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) => { - 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 ; - case 'calendar': - return ; - case 'teaching': - return ; - case 'school': - return ; - case 'department': - return ; - case 'class': - return ; - default: - return ; - } - }, []); - - 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) => 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 ( - - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - + + + + + + + + + {currentNode && ( + + )} - {/* History Menu */} {history.nodes.map((node, index) => ( handleHistoryItemClick(index)} selected={index === history.currentIndex} + dense > - - {getContextIcon(node.type)} + + {getNodeIcon(node.type)} - ))} - - - handleContextChange('profile' as BaseContext)} - startIcon={} - disabled={isDisabled || !userDbName} - > - {visibility.toggleLabels && Profile} - - handleContextChange('school' as BaseContext)} - startIcon={} - disabled={isDisabled || !workerDbName} - > - {visibility.toggleLabels && Institute} - - - - - - - - - - - - setContextMenuAnchor(null)} - > - {getContextItems().map(item => ( - handleContextSelect(item.id as BaseContext)} - disabled={isDisabled} - > - - - - - - ))} - ); -}; \ No newline at end of file +}; diff --git a/src/components/navigation/GraphSidebar.tsx b/src/components/navigation/GraphSidebar.tsx new file mode 100644 index 0000000..1981e8b --- /dev/null +++ b/src/components/navigation/GraphSidebar.tsx @@ -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 = { + 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; + activeRoomId?: string; +} + +function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) { + const [expanded, setExpanded] = useState(false); + const [children, setChildren] = useState(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 ( + + 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' }, + }} + > + + {canExpand && ( + loading + ? + : ( + + {expanded + ? + : } + + ) + )} + + + + {node.label} + + + {canExpand && ( + + {children.map(child => ( + + ))} + + )} + + ); +} + +interface GraphSidebarProps { + open: boolean; + onToggle: () => void; +} + +export function GraphSidebar({ open, onToggle }: GraphSidebarProps) { + const { accessToken } = useAuth(); + const { navigateToNeoNode, context } = useNavigationStore(); + const [tree, setTree] = useState(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 => { + 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 ( + + + + {loading ? ( + + + + ) : tree ? ( + navigateToNeoNode(n)} + onExpand={handleExpand} + activeRoomId={context.node?.id} + /> + ) : null} + + + + + {open + ? + : } + + + + ); +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 9886446..bbf2a6b 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -33,9 +33,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [user_role, setUserRole] = useState(null); const [accessToken, setAccessToken] = useState(null); - const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires + const [loading, setLoading] = useState(true); const [error, setError] = useState(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,57 +71,82 @@ 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': { - persistSession(session ?? null); - if (session?.user) { - try { - const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); - setUser(resolvedUser); - setUserRole(role); - setAccessToken(session.access_token ?? null); - } catch (buildError) { - logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); - setUser(null); - setUserRole(null); - setAccessToken(null); - setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); - } - } else { + 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')); } - // Always clear loading after the first auth event resolves - setLoading(false); - break; - } - case 'SIGNED_OUT': { - persistSession(null); + } else { setUser(null); setUserRole(null); setAccessToken(null); - setLoading(false); - break; } - default: - break; + setLoading(false); + return; + } + + if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') { + persistSession(session ?? null); + if (session?.user) { + try { + const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); + setUser(resolvedUser); + setUserRole(role); + setAccessToken(session.access_token ?? null); + } catch (buildError) { + logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); + setUser(null); + setUserRole(null); + setAccessToken(null); + setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); + } + } else { + setUser(null); + setUserRole(null); + setAccessToken(null); + } + setLoading(false); + return; + } + + if (event === 'SIGNED_OUT') { + persistSession(null); + setUser(null); + setUserRole(null); + setAccessToken(null); + setLoading(false); } } ); return () => subscription.unsubscribe(); - }, [buildUserFromSupabase, persistSession]); + }, [buildUserFromSupabase, persistSession, triggerUserInit]); const signIn = async (email: string, password: string) => { try { diff --git a/src/contexts/NeoUserContext.tsx b/src/contexts/NeoUserContext.tsx index ffb6ce5..7ac68c8 100644 --- a/src/contexts/NeoUserContext.tsx +++ b/src/contexts/NeoUserContext.tsx @@ -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({ }); 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) { diff --git a/src/pages/tldraw/singlePlayerPage.tsx b/src/pages/tldraw/singlePlayerPage.tsx index 5ccbb67..4ab47fa 100644 --- a/src/pages/tldraw/singlePlayerPage.tsx +++ b/src/pages/tldraw/singlePlayerPage.tsx @@ -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(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'); } }); @@ -253,11 +254,16 @@ export default function SinglePlayerPage() { try { setLoadingState({ status: 'loading', error: '' }); - - // Center the node - const nodeData = await loadNodeData(context.node); - await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData); - + + 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: '' }); } catch (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 - const nodeData = await loadNodeData(currentNode); - await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData); + 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 => { - // 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`); - } - - logger.debug('single-player-page', '🔄 Loading node data', { - nodeId: node.id, - nodeType: node.type, - nodeLabel: node.label, - nodeStoragePath: nodeStoragePath - }); + if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`); - 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 || ''), - w: 500, - h: 350, - state: { - parentId: null, - isPageChild: true, - hasChildren: null, - bindings: null - }, - headerColor: theme.headerColor, - backgroundColor: theme.backgroundColor, - isLocked: false, - __primarylabel__: node.type, - uuid_string: node.id, - node_storage_path: nodeStoragePath - }; - } catch (error) { - logger.error('single-player-page', '❌ Error in loadNodeData', { - nodeId: node.id, - nodeType: node.type, - error: error instanceof Error ? error.message : String(error) - }); - throw error; - } + const theme = getThemeFromLabel(node.type); + return { + title: node.label || node.type || '', + w: 500, + h: 350, + state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null }, + headerColor: theme.headerColor, + backgroundColor: theme.backgroundColor, + isLocked: false, + __primarylabel__: node.type, + uuid_string: node.id, + node_storage_path: nodeStoragePath, + }; }; diff --git a/src/services/tldraw/snapshotService.ts b/src/services/tldraw/snapshotService.ts index b6b3b64..ef5ba6c 100644 --- a/src/services/tldraw/snapshotService.ts +++ b/src/services/tldraw/snapshotService.ts @@ -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 { + 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 { + 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 { 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); } } - ); - - const snapshot = response.data; - logger.debug('snapshot-service', '🔍 Snapshot data received', { - hasSnapshot: !!snapshot, - hasDocument: !!snapshot?.document, - hasSession: !!snapshot?.session, - hasSchemaVersion: !!snapshot?.schemaVersion, - schemaVersion: snapshot?.schemaVersion, - snapshotKeys: snapshot ? Object.keys(snapshot) : [] - }); - - if (snapshot && snapshot.document && snapshot.session) { - logger.debug('snapshot-service', 'đŸ“Ĩ Snapshot loaded successfully'); - - if (sharedStore) { - await sharedStore.loadSnapshot(snapshot, setLoadingState); - } else { - logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', { - hasStore: !!store, - snapshotType: typeof snapshot, - snapshotKeys: Object.keys(snapshot), - snapshotSchemaVersion: snapshot?.schemaVersion, - snapshotDocument: !!snapshot?.document, - snapshotSession: !!snapshot?.session - }); - - // Create a defensive copy to ensure the snapshot doesn't get modified - const snapshotCopy = { - schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion, - document: snapshot.document, - session: snapshot.session - }; - - logger.debug('snapshot-service', '🔄 Calling loadSnapshot with defensive copy', { - copySchemaVersion: snapshotCopy.schemaVersion, - copyDocument: !!snapshotCopy.document, - copySession: !!snapshotCopy.session, - storeType: typeof store, - storeIsNull: store === null, - storeIsUndefined: store === undefined, - storeKeys: store ? Object.keys(store) : 'N/A' - }); - - // Debug: Log the snapshot schema sequences - if (snapshotCopy.document?.schema?.sequences) { - logger.debug('snapshot-service', '🔍 Snapshot schema sequences:', snapshotCopy.document.schema.sequences); - const customSequences = Object.keys(snapshotCopy.document.schema.sequences).filter(key => key.includes('cc-')); - logger.debug('snapshot-service', '🔍 Custom shape sequences in snapshot:', customSequences); - } - - // Debug: Log the store schema sequences - if (store?.schema) { - const storeSequences = store.schema.serialize().sequences; - logger.debug('snapshot-service', '🔍 Store schema sequences:', storeSequences); - const storeCustomSequences = Object.keys(storeSequences).filter(key => key.includes('cc-')); - logger.debug('snapshot-service', '🔍 Custom shape sequences in store:', storeCustomSequences); - } - - // Add try-catch around the loadSnapshot call to get more specific error info - try { - // Ensure store is properly initialized before loading snapshot - if (!store) { - throw new Error('Store is null or undefined'); - } - - // Validate snapshot structure before loading - if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) { - throw new Error('Invalid snapshot structure'); - } - - // Check for schema migrations and handle them properly - logger.debug('snapshot-service', '🔄 Checking for schema migrations', { - storeId: store.id, - storeType: typeof store, - storeConstructor: store.constructor.name, - snapshotSchemaVersion: snapshotCopy.schemaVersion, - snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}), - snapshotSessionKeys: Object.keys(snapshotCopy.session || {}) - }); - - try { - // Try to load the snapshot directly first - logger.debug('snapshot-service', '🔄 Attempting to load snapshot directly'); - if (editor) { - loadSnapshot(editor.store, snapshotCopy); - logger.debug('snapshot-service', '✅ Snapshot loaded successfully'); - } else { - // Fallback: use global loadSnapshot if no editor available - logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot'); - loadSnapshot(store, snapshotCopy); - logger.debug('snapshot-service', '✅ Snapshot loaded successfully via global loadSnapshot'); - } - } catch (migrationError) { - // Check if this is a schema migration error that we can safely ignore - const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError); - const isSchemaMigrationError = errorMessage.includes('migration') || - errorMessage.includes('schema') || - errorMessage.includes('Incompatible'); - - if (isSchemaMigrationError) { - logger.debug('snapshot-service', 'â„šī¸ Schema migration warning (non-critical)', { - error: errorMessage - }); - // Continue with empty store - this is expected for some snapshots - } else { - logger.warn('snapshot-service', 'âš ī¸ Unexpected load error', { - error: errorMessage - }); - } - } - - logger.debug('snapshot-service', '✅ loadSnapshot call succeeded'); - setLoadingState({ status: 'ready', error: '' }); - } catch (loadError) { - logger.error('snapshot-service', '❌ loadSnapshot call failed', { - error: loadError instanceof Error ? loadError.message : String(loadError), - storeType: typeof store, - storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function', - snapshotType: typeof snapshotCopy, - snapshotKeys: Object.keys(snapshotCopy) - }); - throw loadError; - } - storageService.set(StorageKeys.NODE_FILE_PATH, nodePath); - } - } else { - logger.error('snapshot-service', '❌ Invalid snapshot format'); - setLoadingState({ status: 'error', error: 'Invalid snapshot format' }); + setLoadingState({ status: 'ready', error: '' }); + return; } + + const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown }; + if (!snap.document || !snap.session) { + logger.warn('snapshot-service', 'âš ī¸ Invalid snapshot format at path', { nodePath }); + setLoadingState({ status: 'ready', error: '' }); + return; + } + + if (sharedStore) { + await sharedStore.loadSnapshot(snapshot, setLoadingState); + return; + } + + const snapshotCopy = { + schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion, + document: snap.document, + session: snap.session, + }; + + try { + if (editor) { + loadSnapshot(editor.store, snapshotCopy as Parameters[1]); + } else { + loadSnapshot(store, snapshotCopy as Parameters[1]); + } + logger.debug('snapshot-service', '✅ Snapshot loaded successfully'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const isSchemaMigration = /migration|schema|Incompatible/i.test(msg); + if (isSchemaMigration) { + logger.debug('snapshot-service', 'â„šī¸ Schema migration warning (non-critical)', { error: msg }); + } else { + logger.warn('snapshot-service', 'âš ī¸ Unexpected loadSnapshot error', { error: msg }); + } + } + + setLoadingState({ status: 'ready', error: '' }); } catch (error) { - logger.error('snapshot-service', '❌ Failed to 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' + setLoadingState({ + status: 'error', + error: error instanceof Error ? error.message : 'Failed to load snapshot', }); } } static async saveNodeSnapshotToDatabase( nodePath: string, - dbName: string, + accessToken: string, store: TLStore ): Promise { 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') { - logger.debug('snapshot-service', '✅ Snapshot saved successfully'); - } else { - throw new Error('Failed to save snapshot'); - } + await storagePut(nodePath, accessToken, snapshot); + logger.debug('snapshot-service', '✅ Snapshot saved successfully'); } catch (error) { - logger.error('snapshot-service', '❌ Failed to save snapshot', { - error: error instanceof Error ? error.message : 'Unknown error' + logger.error('snapshot-service', '❌ Failed to save snapshot', { + error: error instanceof Error ? error.message : 'Unknown error', }); throw error; } } private async saveCurrentSnapshot(nodePath: string): Promise { - 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 { + private async loadSnapshotForNode(node: { node_storage_path: string }): Promise { + if (!this._accessToken) { + logger.debug('snapshot-service', 'âš ī¸ No access token — snapshot load skipped'); + return; + } 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)'); - return; - } - - logger.debug('snapshot-service', 'đŸ“Ĩ Loading snapshot', { - nodePath: node.node_storage_path, - dbName, - userType: user.user_type, - username: user.username - }); - 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 { - 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 { + 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 { - try { - logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', { - from: fromNode.node_storage_path, - to: toNode.node_storage_path, - currentPath: this.currentNodePath - }); + private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise { + if (this.isSaving || this.isLoading) { + this.pendingOperation = { + save: fromNode?.node_storage_path, + load: toNode.node_storage_path, + }; + return; + } - // If we're already in a navigation operation, queue this one - if (this.isSaving || this.isLoading) { - this.pendingOperation = { - save: fromNode.node_storage_path || undefined, - load: toNode.node_storage_path - }; - logger.debug('snapshot-service', 'âŗ Queued navigation operation', this.pendingOperation); - return; - } + this.currentNodePath = null; - // Clear the store before loading new snapshot - logger.debug('snapshot-service', '🔄 Clearing store'); - this.currentNodePath = null; - logger.debug('snapshot-service', '🧹 Cleared current node path'); + if (toNode.node_storage_path) { + await this.loadSnapshotForNode(toNode); + } - // 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; - this.pendingOperation = null; - await this.handleNavigationStart( - operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null, - operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null - ); - logger.debug('snapshot-service', '✅ Completed pending operation'); - } - } catch (error) { - logger.error('snapshot-service', '❌ Error during navigation snapshot handling', { - error: error instanceof Error ? error.message : 'Unknown error', - fromPath: fromNode.node_storage_path, - toPath: toNode.node_storage_path - }); - throw error; + if (this.pendingOperation) { + const op = this.pendingOperation; + this.pendingOperation = null; + await this.handleNavigationStart( + op.save ? { node_storage_path: op.save } : null, + op.load ? { node_storage_path: op.load } : null + ); } } 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 { 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'); } -} \ No newline at end of file +} diff --git a/src/stores/navigationStore.ts b/src/stores/navigationStore.ts index 52673c3..6b3493d 100644 --- a/src/stores/navigationStore.ts +++ b/src/stores/navigationStore.ts @@ -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, +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,402 +51,288 @@ function validateContextTransition( updates: Partial ): 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' + 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; - setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise; - setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise; - - // Node Navigation - navigate: (nodeId: string, dbName: string) => Promise; - navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise; - - // History Navigation - goBack: () => void; - goForward: () => void; - - // Utility Methods - refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise; -} +export const useNavigationStore = create((set, get) => { + const pgFetch = async ( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + table: string, + options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} + ): Promise => { + 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 = { + '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; + }; -export interface NavigationState { - context: { - main: NodeContext; - base: NodeContext; - extended?: string; - node: NavigationNode; - history: { - nodes: NavigationNode[]; - currentIndex: number; + const getOrCreateDefaultRoom = async (contextType: string): Promise => { + const userId = get()._userId; + if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID'); + + const rooms = await pgFetch('GET', 'whiteboard_rooms', { + query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`, + }); + + if (rooms && rooms.length > 0) { + const room = rooms[0]; + return { + id: room.id, + node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`, + label: room.name, + type: 'workspace', + }; + } + + const storagePath = `${userId}/workspaces/${contextType}_default.json`; + const room = await pgFetch('POST', 'whiteboard_rooms', { + body: { + user_id: userId, + name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`, + context_type: contextType, + is_default: true, + storage_path: storagePath, + }, + prefer: 'return=representation', + single: true, + }); + + if (!room) throw new Error('Failed to create default whiteboard room'); + logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id }); + + return { + id: room.id, + node_storage_path: room.storage_path || storagePath, + label: room.name, + type: 'workspace', }; }; - // ... rest of the state interface ... -} -export const useNavigationStore = create((set, get) => ({ - context: initialState, - isLoading: false, - error: null, + return { + _accessToken: null, + _userId: null, + setAuthInfo: (token: string | null, userId: string | null) => { + set({ _accessToken: token, _userId: userId }); + }, - switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => { - 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 - }); + context: initialState, + isLoading: false, + error: null, + + switchContext: async (contextSwitch: UnifiedContextSwitch, _userDbName: string | null = null, _workerDbName: string | null = null) => { + if (!get()._accessToken || !get()._userId) { + logger.warn('navigation-context', 'âš ī¸ switchContext called without auth — skipping'); return; } + try { + set({ isLoading: true, error: null }); + const currentState = get().context; + let newState: NavigationContextState = { ...currentState, node: null }; - 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; - - // 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); + if (contextSwitch.main) { + newState = validateContextTransition(newState, { main: contextSwitch.main }); + if (!contextSwitch.skipBaseContextLoad) { + newState.base = getDefaultBaseForMain(contextSwitch.main); + } } - logger.debug('navigation-state', '✅ Main context updated', { - previous: currentState.main, - new: newState.main, - defaultBase: newState.base - }); - } - - // Update base context if provided - if (contextSwitch.base) { - newState = validateContextTransition(newState, { base: contextSwitch.base }); - logger.debug('navigation-state', '✅ Base context updated', { - previous: currentState.base, - new: newState.base - }); - } - - logger.debug('navigation-state', '✅ Context validation complete', { - validatedState: newState, - originalState: currentState - }); - - // Determine which context to use for the node - const targetContext = contextSwitch.base || - contextSwitch.extended || - (contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : - newState.base); - - // Get database name - const dbName = getContextDatabase(newState, userDbName, workerDbName); - - logger.debug('context-switch', '🔍 Fetching default node for context', { - targetContext, - dbName, - currentState: newState - }); - - // Get default node for the final context - const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName); - - if (!defaultNode) { - const errorMsg = `No default node found for context: ${targetContext}`; - logger.error('context-switch', '❌ Default node fetch failed', { targetContext }); - set({ - error: errorMsg, - isLoading: false - }); - return; - } - - logger.debug('context-switch', '✨ Default node fetched', { - nodeId: defaultNode.id, - node_storage_path: defaultNode.node_storage_path, - type: defaultNode.type - }); - - // Update history and state - const newHistory = addToHistory(currentState.history, defaultNode); - logger.debug('history-management', '📚 History updated', { - previousState: currentState.history, - newState: newHistory, - addedNode: defaultNode - }); - - set({ - context: { - ...newState, - node: defaultNode, - history: newHistory - }, - isLoading: false, - error: null - }); - - logger.debug('navigation-context', '✅ Context switch completed', { - finalState: { - main: newState.main, - base: newState.base, - nodeId: defaultNode.id + if (contextSwitch.base) { + newState = validateContextTransition(newState, { base: contextSwitch.base }); } - }); - } catch (error) { - logger.error('navigation-context', '❌ Failed to switch context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to switch context', - isLoading: false - }); - } - }, - goBack: () => { - const 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 - } - }); - } - }, + const targetContext = contextSwitch.base || + contextSwitch.extended || + (contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base); - goForward: () => { - const currentState = get().context; - if (currentState.history.currentIndex < currentState.history.nodes.length - 1) { - const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1); - const node = getCurrentHistoryNode(newHistory); - set({ - context: { - ...currentState, - node, - history: newHistory - } - }); - } - }, + const defaultNode = await getOrCreateDefaultRoom(targetContext); + const newHistory = addToHistory(currentState.history, defaultNode); - setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => { - try { - // Use switchContext instead of direct implementation - await get().switchContext({ main }, userDbName, workerDbName); - } catch (error) { - logger.error('navigation', '❌ Failed to set main context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to set main context', - isLoading: false - }); - } - }, - - setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => { - try { - // Use switchContext instead of direct implementation - await get().switchContext({ base }, userDbName, workerDbName); - } catch (error) { - logger.error('navigation', '❌ Failed to set base context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to set base context', - isLoading: false - }); - } - }, - - setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => { - try { - // Use switchContext instead of direct implementation - await get().switchContext({ extended }, userDbName, workerDbName); - } catch (error) { - logger.error('navigation', '❌ Failed to set extended context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to set extended context', - isLoading: false - }); - } - }, - - navigate: async (nodeId: string, dbName: string) => { - try { - set({ isLoading: true, error: null }); - - // Check if we already have this node in history - const currentState = get().context; - const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); - - // If node exists in history, just navigate to it - if (existingNodeIndex !== -1) { - logger.debug('navigation', '📍 Navigating to existing node in history', { - nodeId, - historyIndex: existingNodeIndex, - currentIndex: currentState.history.currentIndex - }); - - const newHistory = navigateHistory(currentState.history, existingNodeIndex); - const node = getCurrentHistoryNode(newHistory); - set({ - context: { - ...currentState, - node, - history: newHistory - }, + context: { ...newState, node: defaultNode, history: newHistory }, isLoading: false, - error: null + error: null, }); + logger.debug('navigation-context', '✅ Context switch complete', { + main: newState.main, base: newState.base, nodeId: defaultNode.id, + }); + } catch (error) { + logger.error('navigation-context', '❌ switchContext failed', error); + set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false }); + } + }, + + goBack: () => { + const currentState = get().context; + if (currentState.history.currentIndex > 0) { + const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1); + set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } }); + } + }, + + goForward: () => { + const currentState = get().context; + if (currentState.history.currentIndex < currentState.history.nodes.length - 1) { + const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1); + set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } }); + } + }, + + setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => { + await get().switchContext({ main }, userDbName, workerDbName); + }, + + setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => { + await get().switchContext({ base }, userDbName, workerDbName); + }, + + setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => { + await get().switchContext({ extended }, userDbName, workerDbName); + }, + + navigate: async (nodeId: string, _dbName: string) => { + try { + set({ isLoading: true, error: null }); + if (!get()._accessToken) { set({ isLoading: false }); return; } + + const currentState = get().context; + const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); + if (existingIndex !== -1) { + const newHistory = navigateHistory(currentState.history, existingIndex); + set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false }); + return; + } + + const rooms = await pgFetch('GET', 'whiteboard_rooms', { + query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`, + }); + if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`); + + const room = rooms[0]; + const node: NavigationNode = { + id: room.id, + node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`, + label: room.name, + type: 'workspace', + }; + const newHistory = addToHistory(currentState.history, node); + set({ context: { ...currentState, node, history: newHistory }, isLoading: false }); + } catch (error) { + logger.error('navigation', '❌ navigate failed', error); + set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false }); + } + }, + + navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => { + if (!isValidNodeType(node.type)) { + logger.warn('navigation', `âš ī¸ navigateToNode called with non-graph type: ${node.type} — navigating anyway`); + } + await get().navigate(node.id, userDbName || ''); + }, + + refreshNavigationState: async (_userDbName: string | null, _workerDbName: string | null) => { + try { + set({ isLoading: true, error: null }); + const currentNode = get().context.node; + if (currentNode && get()._accessToken) { + const rooms = await pgFetch('GET', 'whiteboard_rooms', { + query: `id=eq.${currentNode.id}`, + }); + if (rooms && rooms.length > 0) { + const room = rooms[0]; + set({ + context: { + ...get().context, + node: { + id: room.id, + node_storage_path: room.storage_path || currentNode.node_storage_path, + label: room.name, + type: 'workspace', + }, + }, + }); + } + } + set({ isLoading: false }); + } catch (error) { + logger.error('navigation', '❌ refreshNavigationState failed', error); + set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false }); + } + }, + + navigateToNeoNode: async (neoNode: NeoGraphNode) => { + const userId = get()._userId; + if (!userId || !get()._accessToken) { + logger.warn('navigation', 'âš ī¸ navigateToNeoNode called without auth'); return; } - - // Fetch new node data - const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName); - if (!nodeData) { - throw new Error(`Node not found: ${nodeId}`); - } - - const node: NavigationNode = { - id: nodeId, - node_storage_path: nodeData.node_data.node_storage_path || '', - label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId, - type: nodeData.node_type - }; - - logger.debug('navigation', '📍 Adding new node to history', { - nodeId: node.id, - type: node.type, - node_storage_path: node.node_storage_path - }); - - // Add to history and update state - const newHistory = addToHistory(currentState.history, node); - set({ - context: { - ...currentState, - node, - history: newHistory - }, - isLoading: false, - error: null - }); - } catch (error) { - logger.error('navigation', '❌ Failed to navigate:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to navigate', - isLoading: false - }); - } - }, - - navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => { - try { - set({ isLoading: true, error: null }); - - if (!isValidNodeType(node.type)) { - throw new Error(`Invalid node type: ${node.type}`); - } - - const dbName = getContextDatabase(get().context, userDbName, workerDbName); - await get().navigate(node.id, dbName); - } catch (error) { - logger.error('navigation', '❌ Failed to navigate to node:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to navigate to node', - isLoading: false - }); - } - }, - - refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => { - try { - set({ isLoading: true, error: null }); - const currentState = get().context; - - if (currentState.node) { - const dbName = getContextDatabase(currentState, userDbName, workerDbName); - const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName); - if (nodeData) { - const node: NavigationNode = { - id: currentState.node.id, - node_storage_path: nodeData.node_data.node_storage_path || '', - label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id, - type: nodeData.node_type - }; - set({ - context: { - ...currentState, - node - } + try { + set({ isLoading: true, error: null }); + const existing = await pgFetch('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('POST', 'whiteboard_rooms', { + body: { + user_id: userId, + name: neoNode.label, + context_type: neoNode.node_type.toLowerCase(), + is_default: false, + storage_path: storagePath, + neo4j_node_id: neoNode.neo4j_node_id, + neo4j_db_name: neoNode.neo4j_db_name, + node_type: neoNode.node_type, + }, + prefer: 'return=representation', + single: true, }); + if (!created) throw new Error('Failed to create whiteboard room for node'); + room = created; } + const node: NavigationNode = { + id: room.id, + node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`, + label: room.name, + type: neoNode.node_type, + }; + const currentState = get().context; + const newHistory = addToHistory(currentState.history, node); + set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null }); + logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode }); + } catch (error) { + logger.error('navigation', '❌ navigateToNeoNode failed', error); + set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false }); } - - set({ isLoading: false }); - } catch (error) { - logger.error('navigation', '❌ Failed to refresh navigation state:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to refresh navigation state', - isLoading: false - }); - } - } -})); \ No newline at end of file + }, + }; +}); diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts index c65137f..f3574bd 100644 --- a/src/stores/transcriptionStore.ts +++ b/src/stores/transcriptionStore.ts @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import { supabase } from '../supabaseClient'; export interface TranscriptionSegment { text: string; @@ -168,545 +167,577 @@ interface TranscriptionState { clearKeywordMatches: () => void; } -export const useTranscriptionStore = create((set, get) => ({ - isRecording: false, - isConnecting: false, - activeSession: null, - _accessToken: null, - _userId: null, - completedSegments: [], - serverWindow: [], - currentSegment: null, - pendingCanvasEvents: [], - timetableContext: null, - wordCount: 0, - elapsedSeconds: 0, - - // LLM config initialized from localStorage - llmConfig: loadLLMConfig(), - - // Summary state - summaryText: null, - isGeneratingSummary: false, - summaryError: null, - - // Export state - isExporting: false, - exportError: null, - - // Keyword state - keywordWatches: [], - keywordMatches: [], - - setTimetableContext: (context) => { - set({ timetableContext: context }); - }, - - setAuthInfo: (token: string | null, userId: string | null) => { - set({ _accessToken: token, _userId: userId }); - }, - - startSession: async (timetableTag?: TimetablePeriod) => { - set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); - - // Create session in Supabase - try { - const { _accessToken: token, _userId: userId } = get(); - if (!token || !userId) { - console.error('No authenticated user'); - return; - } - - const sessionData = { - user_id: userId, - title: timetableTag?.event_label || 'Untitled Session', - canvas_type: 'teaching-canvas', - timetable_period_id: timetableTag?.period_id || null, - timetable_event_type: timetableTag?.event_type || null, - timetable_event_label: timetableTag?.event_label || null, - auto_tagged: !!timetableTag, - }; - - const { data, error } = await supabase - .from('transcription_sessions') - .insert(sessionData) - .select() - .single(); - - if (error) { - console.error('Failed to create session:', error); - return; - } - - set({ activeSession: data }); - } catch (error) { - console.error('Error starting session:', error); +export const useTranscriptionStore = create((set, get) => { + // Direct PostgREST fetch — uses stored _accessToken, no GoTrueClient lock. + const pgFetch = async ( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + table: string, + options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} + ): Promise => { + 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 = { + '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; + }; - stopSession: async () => { - const { activeSession, currentSegment, completedSegments } = get(); + return { + isRecording: false, + isConnecting: false, + activeSession: null, + _accessToken: null, + _userId: null, + completedSegments: [], + serverWindow: [], + currentSegment: null, + pendingCanvasEvents: [], + timetableContext: null, + wordCount: 0, + elapsedSeconds: 0, - // The live segment (currentSegment) was never added to completedSegments — flush it now. - let newCompleted = [...completedSegments]; - if (currentSegment && currentSegment.text.trim()) { - const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5); - if (!alreadyIn) { - const idx = newCompleted.length; - newCompleted.push({ ...currentSegment, isFinal: true }); - if (activeSession) { - supabase.from('transcription_segments').insert({ - 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); }); - } - } - } + // LLM config initialized from localStorage + llmConfig: loadLLMConfig(), - const finalWordCount = newCompleted.reduce( - (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, - 0 - ); + // Summary state + summaryText: null, + isGeneratingSummary: false, + summaryError: null, + + // Export state + isExporting: false, + exportError: null, + + // Keyword state + keywordWatches: [], + keywordMatches: [], + + setTimetableContext: (context) => { + set({ timetableContext: context }); + }, + + setAuthInfo: (token: string | null, userId: string | null) => { + set({ _accessToken: token, _userId: userId }); + }, + + startSession: async (timetableTag?: TimetablePeriod) => { + set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); - if (activeSession) { try { - await supabase - .from('transcription_sessions') - .update({ - 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); - } - } - - set({ - isRecording: false, - isConnecting: false, - activeSession: null, - completedSegments: newCompleted, - serverWindow: [], - currentSegment: null, - wordCount: finalWordCount, - }); - }, - - updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => { - const { completedSegments, activeSession } = get(); - - if (segments.length === 0) return; - - // The server marks every finalized segment with completed=true and the live - // one with completed=false. Rather than relying on window-scroll detection - // (which can miss segments when the server creates several at once), we - // directly merge every completed segment from this message into the store. - // This guarantees no gaps: any segment the server says is complete is captured - // immediately, regardless of how many were created since the last message. - const serverCompleted = isLastLive ? segments.slice(0, -1) : segments; - - let newCompleted = [...completedSegments]; - const toSave: Array<{ seg: ServerSegment; idx: number }> = []; - - for (const seg of serverCompleted) { - if (!seg.text.trim()) continue; - const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5); - if (existingIdx >= 0) { - // Server refined an existing segment — update text and end time in place. - newCompleted[existingIdx] = { - ...newCompleted[existingIdx], - text: seg.text, - end: seg.end, - }; - } else { - const newIdx = newCompleted.length; - newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end }); - toSave.push({ seg, idx: newIdx }); - } - } - - // Keep sorted by start time so display order is always correct. - newCompleted.sort((a, b) => a.start - b.start); - - // Persist and keyword-check only truly new segments. - if (toSave.length > 0) { - const elapsed = get().elapsedSeconds; - for (const { seg, idx } of toSave) { - if (activeSession) { - supabase.from('transcription_segments').insert({ - 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); }); + const { _userId: userId } = get(); + if (!userId) { + console.error('No authenticated user'); + return; } - get().checkSegmentForKeywords(seg.text, elapsed); + + const sessionData = { + user_id: userId, + title: timetableTag?.event_label || 'Untitled Session', + canvas_type: 'teaching-canvas', + timetable_period_id: timetableTag?.period_id || null, + timetable_event_type: timetableTag?.event_type || null, + timetable_event_label: timetableTag?.event_label || null, + auto_tagged: !!timetableTag, + }; + + const data = await pgFetch('POST', 'transcription_sessions', { + body: sessionData, + prefer: 'return=representation', + single: true, + }); + + if (!data) { + console.error('Failed to create session: no data returned'); + return; + } + + set({ activeSession: data }); + } catch (error) { + console.error('Error starting session:', error); } - } + }, - const lastSeg = segments[segments.length - 1]; - const newCurrentSegment: TranscriptionSegment | null = isLastLive - ? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end } - : null; + stopSession: async () => { + const { activeSession, currentSegment, completedSegments } = get(); - const newWordCount = newCompleted.reduce( - (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, - 0 - ); + // The live segment (currentSegment) was never added to completedSegments — flush it now. + let newCompleted = [...completedSegments]; + if (currentSegment && currentSegment.text.trim()) { + const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5); + if (!alreadyIn) { + const idx = newCompleted.length; + newCompleted.push({ ...currentSegment, isFinal: true }); + if (activeSession) { + pgFetch('POST', 'transcription_segments', { + body: { + session_id: activeSession.id, + sequence_index: idx, + text: currentSegment.text, + start_seconds: currentSegment.start, + end_seconds: currentSegment.end, + is_final: true, + }, + }).catch(err => console.error('Failed to save live segment on stop:', err)); + } + } + } - set({ - serverWindow: segments, - completedSegments: newCompleted, - currentSegment: newCurrentSegment, - wordCount: newWordCount, - }); - }, - - saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { - const { completedSegments, currentSegment, activeSession } = get(); - - if (isFinal) { - // Deduplicate by start time: if a segment with this start already exists, update it - // rather than appending. This prevents doubles when the stability timer fires and - // the segment later appears in the server's finalized list with a slightly extended end. - const existingIdx = completedSegments.findIndex( - (s) => Math.abs(s.start - metadata.start) < 0.5 + const finalWordCount = newCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 ); - let newCompleted: TranscriptionSegment[]; - let isNew: boolean; - if (existingIdx >= 0) { - newCompleted = completedSegments.map((s, i) => - i === existingIdx ? { text, isFinal: true, ...metadata } : s - ); - isNew = false; - } else { - newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }]; - isNew = true; + if (activeSession) { + try { + await pgFetch('PATCH', 'transcription_sessions', { + query: `id=eq.${activeSession.id}`, + body: { + ended_at: new Date().toISOString(), + word_count: finalWordCount, + segment_count: newCompleted.length, + }, + }); + } catch (error) { + console.error('Failed to end session:', error); + } } + set({ + isRecording: false, + isConnecting: false, + activeSession: null, + completedSegments: newCompleted, + serverWindow: [], + currentSegment: null, + wordCount: finalWordCount, + }); + }, + + updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => { + const { completedSegments, activeSession } = get(); + + if (segments.length === 0) return; + + // The server marks every finalized segment with completed=true and the live + // one with completed=false. Rather than relying on window-scroll detection + // (which can miss segments when the server creates several at once), we + // directly merge every completed segment from this message into the store. + // This guarantees no gaps: any segment the server says is complete is captured + // immediately, regardless of how many were created since the last message. + const serverCompleted = isLastLive ? segments.slice(0, -1) : segments; + + let newCompleted = [...completedSegments]; + const toSave: Array<{ seg: ServerSegment; idx: number }> = []; + + for (const seg of serverCompleted) { + if (!seg.text.trim()) continue; + const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5); + if (existingIdx >= 0) { + // Server refined an existing segment — update text and end time in place. + newCompleted[existingIdx] = { + ...newCompleted[existingIdx], + text: seg.text, + end: seg.end, + }; + } else { + const newIdx = newCompleted.length; + newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end }); + toSave.push({ seg, idx: newIdx }); + } + } + + // Keep sorted by start time so display order is always correct. + newCompleted.sort((a, b) => a.start - b.start); + + // Persist and keyword-check only truly new segments. + if (toSave.length > 0) { + const elapsed = get().elapsedSeconds; + for (const { seg, idx } of toSave) { + if (activeSession) { + pgFetch('POST', 'transcription_segments', { + body: { + session_id: activeSession.id, + sequence_index: idx, + text: seg.text, + start_seconds: seg.start, + end_seconds: seg.end, + is_final: true, + }, + }).catch(err => console.error('Failed to save segment:', err)); + } + get().checkSegmentForKeywords(seg.text, elapsed); + } + } + + const lastSeg = segments[segments.length - 1]; + const newCurrentSegment: TranscriptionSegment | null = isLastLive + ? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end } + : null; + const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); - set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); + set({ + serverWindow: segments, + completedSegments: newCompleted, + currentSegment: newCurrentSegment, + wordCount: newWordCount, + }); + }, - if (isNew && activeSession) { - try { - await supabase.from('transcription_segments').insert({ - 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); + saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { + const { completedSegments, currentSegment, activeSession } = get(); + + if (isFinal) { + // Deduplicate by start time: if a segment with this start already exists, update it + // rather than appending. This prevents doubles when the stability timer fires and + // the segment later appears in the server's finalized list with a slightly extended end. + const existingIdx = completedSegments.findIndex( + (s) => Math.abs(s.start - metadata.start) < 0.5 + ); + + let newCompleted: TranscriptionSegment[]; + let isNew: boolean; + if (existingIdx >= 0) { + newCompleted = completedSegments.map((s, i) => + i === existingIdx ? { text, isFinal: true, ...metadata } : s + ); + isNew = false; + } else { + newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }]; + isNew = true; } - } - } else { - // In-progress segment. If the start time jumped to a new position, the previous - // live segment is done — auto-commit it before switching. - if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) { - const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }]; - const autoWordCount = autoCompleted.reduce( + + const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); - set({ completedSegments: autoCompleted, wordCount: autoWordCount }); - if (activeSession) { - supabase.from('transcription_segments').insert({ - 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); }); - } - } - set({ currentSegment: { text, isFinal: false, ...metadata } }); - } - }, - resetSession: () => { - set({ - isRecording: false, - isConnecting: false, - completedSegments: [], - serverWindow: [], - currentSegment: null, - wordCount: 0, - elapsedSeconds: 0, - activeSession: null, - pendingCanvasEvents: [], - timetableContext: null, - keywordMatches: [], - }); - }, + set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); - tickElapsed: () => { - set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 })); - }, - - addCanvasEvent: (event) => { - set((state) => ({ - pendingCanvasEvents: [...state.pendingCanvasEvents, event], - })); - }, - - flushCanvasEvents: async () => { - const { pendingCanvasEvents, activeSession } = get(); - - if (pendingCanvasEvents.length === 0) return; - - const eventsToFlush = [...pendingCanvasEvents]; - - try { - for (const event of eventsToFlush) { - await supabase.from('canvas_events').insert({ - session_id: activeSession?.id || null, - user_id: get()._userId || '', - timestamp: new Date().toISOString(), - session_elapsed_seconds: event.sessionElapsedSeconds || null, - event_type: event.eventType, - event_payload: event.payload || {}, - canvas_snapshot_url: event.snapshotUrl || null, - tldraw_page_id: event.pageId || null, - tldraw_shape_ids: event.shapeIds || null, - }); - } - - set({ pendingCanvasEvents: [] }); - } catch (error) { - console.error('Failed to flush canvas events:', error); - } - }, - - loadSessions: async (): Promise => { - try { - 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 []; - } - - return data || []; - } catch (error) { - console.error('Error loading sessions:', error); - return []; - } - }, - - // LLM config actions - persist to localStorage only - setLLMConfig: (partialConfig: Partial) => { - const current = get().llmConfig; - const updated = { ...current, ...partialConfig }; - saveLLMConfig(updated); - set({ llmConfig: updated }); - }, - - getLLMConfig: (): LLMConfig => { - return get().llmConfig; - }, - - // Summary actions - setSummaryText: (text: string | null) => { - set({ summaryText: text }); - }, - - setIsGeneratingSummary: (generating: boolean) => { - set({ isGeneratingSummary: generating }); - }, - - setSummaryError: (error: string | null) => { - set({ summaryError: error }); - }, - - // Export actions - exportSession: async (sessionId: string, format: ExportFormat) => { - set({ isExporting: true, exportError: null }); - - try { - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ format }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`); - } - - // Get filename from Content-Disposition header or use default - const disposition = response.headers.get('Content-Disposition'); - let filename = `transcription-export.${format}`; - if (disposition) { - const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/); - if (match && match[1]) { - filename = match[1].replace(/["'\\]/g, ''); - } - } - - // Trigger browser download - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - } catch (error) { - console.error('Failed to export session:', error); - set({ exportError: error instanceof Error ? error.message : 'Failed to export session' }); - } finally { - set({ isExporting: false }); - } - }, - - setExportError: (error: string | null) => { - set({ exportError: error }); - }, - - loadKeywordWatches: async () => { - try { - const { _accessToken: token } = get(); - if (!token) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - if (!response.ok) return; - const watches = await response.json(); - set({ keywordWatches: watches }); - } catch (error) { - console.error('Failed to load keyword watches:', error); - } - }, - - addKeywordWatch: async (keyword: string) => { - try { - const { _accessToken: token, _userId: userId } = get(); - if (!token || !userId) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ - user_id: userId, - keyword: keyword.trim(), - match_type: 'contains', - action: 'alert', - }), - }); - if (!response.ok) return; - const newWatch = await response.json(); - set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] })); - } catch (error) { - console.error('Failed to add keyword watch:', error); - } - }, - - deleteKeywordWatch: async (watchId: string) => { - try { - const { _accessToken: token } = get(); - if (!token) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` }, - }); - set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); - } catch (error) { - console.error('Failed to delete keyword watch:', error); - } - }, - - checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => { - const { keywordWatches, activeSession } = get(); - if (keywordWatches.length === 0) return; - - const lowerText = text.toLowerCase(); - const matches: KeywordMatch[] = []; - - for (const watch of keywordWatches) { - if (!watch.is_active) continue; - const lowerKeyword = watch.keyword.toLowerCase(); - const matched = - watch.match_type === 'exact' - ? lowerText === lowerKeyword - : watch.match_type === 'starts_with' - ? lowerText.startsWith(lowerKeyword) - : lowerText.includes(lowerKeyword); - - if (matched) { - matches.push({ - keyword: watch.keyword, - watch_id: watch.id, - segment_text: text, - elapsed_seconds: elapsedSeconds, - matched_at: new Date().toISOString(), - }); - - if (activeSession) { + if (isNew && activeSession) { try { - const { _accessToken: _kwToken } = get(); - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - await fetch(`${apiBaseUrl}/transcribe/keywords/events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(_kwToken ? { 'Authorization': `Bearer ${_kwToken}` } : {}), - }, - body: JSON.stringify({ + await pgFetch('POST', 'transcription_segments', { + body: { session_id: activeSession.id, - keyword_watch_id: watch.id, - keyword_text: watch.keyword, - matched_in_text: text, - session_elapsed_seconds: elapsedSeconds, - }), + sequence_index: newCompleted.length - 1, + text, + start_seconds: metadata.start, + end_seconds: metadata.end, + is_final: true, + }, }); } catch (error) { - console.error('Failed to log keyword event:', error); + console.error('Failed to save segment:', error); + } + } + } else { + // In-progress segment. If the start time jumped to a new position, the previous + // live segment is done — auto-commit it before switching. + if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) { + const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }]; + const autoWordCount = autoCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 + ); + set({ completedSegments: autoCompleted, wordCount: autoWordCount }); + if (activeSession) { + pgFetch('POST', 'transcription_segments', { + body: { + session_id: activeSession.id, + sequence_index: autoCompleted.length - 1, + text: currentSegment.text, + start_seconds: currentSegment.start, + end_seconds: currentSegment.end, + is_final: true, + }, + }).catch(err => console.error('Failed to save auto-committed segment:', err)); + } + } + set({ currentSegment: { text, isFinal: false, ...metadata } }); + } + }, + + resetSession: () => { + set({ + isRecording: false, + isConnecting: false, + completedSegments: [], + serverWindow: [], + currentSegment: null, + wordCount: 0, + elapsedSeconds: 0, + activeSession: null, + pendingCanvasEvents: [], + timetableContext: null, + keywordMatches: [], + }); + }, + + tickElapsed: () => { + set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 })); + }, + + addCanvasEvent: (event) => { + set((state) => ({ + pendingCanvasEvents: [...state.pendingCanvasEvents, event], + })); + }, + + flushCanvasEvents: async () => { + const { pendingCanvasEvents, activeSession } = get(); + + if (pendingCanvasEvents.length === 0) return; + + const eventsToFlush = [...pendingCanvasEvents]; + + try { + for (const event of eventsToFlush) { + await pgFetch('POST', 'canvas_events', { + body: { + session_id: activeSession?.id || null, + user_id: get()._userId || '', + timestamp: new Date().toISOString(), + session_elapsed_seconds: event.sessionElapsedSeconds || null, + event_type: event.eventType, + event_payload: event.payload || {}, + canvas_snapshot_url: event.snapshotUrl || null, + tldraw_page_id: event.pageId || null, + tldraw_shape_ids: event.shapeIds || null, + }, + }); + } + + set({ pendingCanvasEvents: [] }); + } catch (error) { + console.error('Failed to flush canvas events:', error); + } + }, + + loadSessions: async (): Promise => { + try { + const { _userId: userId } = get(); + if (!userId) return []; + + const data = await pgFetch('GET', 'transcription_sessions', { + query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`, + }); + + return data || []; + } catch (error) { + console.error('Error loading sessions:', error); + return []; + } + }, + + // LLM config actions - persist to localStorage only + setLLMConfig: (partialConfig: Partial) => { + const current = get().llmConfig; + const updated = { ...current, ...partialConfig }; + saveLLMConfig(updated); + set({ llmConfig: updated }); + }, + + getLLMConfig: (): LLMConfig => { + return get().llmConfig; + }, + + // Summary actions + setSummaryText: (text: string | null) => { + set({ summaryText: text }); + }, + + setIsGeneratingSummary: (generating: boolean) => { + set({ isGeneratingSummary: generating }); + }, + + setSummaryError: (error: string | null) => { + set({ summaryError: error }); + }, + + // Export actions + exportSession: async (sessionId: string, format: ExportFormat) => { + set({ isExporting: true, exportError: null }); + + try { + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ format }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`); + } + + // Get filename from Content-Disposition header or use default + const disposition = response.headers.get('Content-Disposition'); + let filename = `transcription-export.${format}`; + if (disposition) { + const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/); + if (match && match[1]) { + filename = match[1].replace(/["'\\]/g, ''); + } + } + + // Trigger browser download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error('Failed to export session:', error); + set({ exportError: error instanceof Error ? error.message : 'Failed to export session' }); + } finally { + set({ isExporting: false }); + } + }, + + setExportError: (error: string | null) => { + set({ exportError: error }); + }, + + loadKeywordWatches: async () => { + try { + const { _accessToken: token } = get(); + if (!token) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!response.ok) return; + const watches = await response.json(); + set({ keywordWatches: watches }); + } catch (error) { + console.error('Failed to load keyword watches:', error); + } + }, + + addKeywordWatch: async (keyword: string) => { + try { + const { _accessToken: token, _userId: userId } = get(); + if (!token || !userId) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + user_id: userId, + keyword: keyword.trim(), + match_type: 'contains', + action: 'alert', + }), + }); + if (!response.ok) return; + const newWatch = await response.json(); + set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] })); + } catch (error) { + console.error('Failed to add keyword watch:', error); + } + }, + + deleteKeywordWatch: async (watchId: string) => { + try { + const { _accessToken: token } = get(); + if (!token) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` }, + }); + set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); + } catch (error) { + console.error('Failed to delete keyword watch:', error); + } + }, + + checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => { + const { keywordWatches, activeSession } = get(); + if (keywordWatches.length === 0) return; + + const lowerText = text.toLowerCase(); + const matches: KeywordMatch[] = []; + + for (const watch of keywordWatches) { + if (!watch.is_active) continue; + const lowerKeyword = watch.keyword.toLowerCase(); + const matched = + watch.match_type === 'exact' + ? lowerText === lowerKeyword + : watch.match_type === 'starts_with' + ? lowerText.startsWith(lowerKeyword) + : lowerText.includes(lowerKeyword); + + if (matched) { + matches.push({ + keyword: watch.keyword, + watch_id: watch.id, + segment_text: text, + elapsed_seconds: elapsedSeconds, + matched_at: new Date().toISOString(), + }); + + if (activeSession) { + try { + const { _accessToken: kwToken } = get(); + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + await fetch(`${apiBaseUrl}/transcribe/keywords/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}), + }, + body: JSON.stringify({ + session_id: activeSession.id, + keyword_watch_id: watch.id, + keyword_text: watch.keyword, + matched_in_text: text, + session_elapsed_seconds: elapsedSeconds, + }), + }); + } catch (error) { + console.error('Failed to log keyword event:', error); + } } } } - } - if (matches.length > 0) { - set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] })); - } - }, + if (matches.length > 0) { + set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] })); + } + }, - clearKeywordMatches: () => { - set({ keywordMatches: [] }); - }, -})); + clearKeywordMatches: () => { + set({ keywordMatches: [] }); + }, + }; +}); diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 7fbe598..e792be0 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -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; @@ -239,6 +246,7 @@ export interface NavigationActions { // Node Navigation navigate: (nodeId: string, dbName: string) => Promise; navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise; + navigateToNeoNode: (neoNode: NeoGraphNode) => Promise; // History Navigation goBack: () => void; diff --git a/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx b/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx index edfe71f..32093f4 100644 --- a/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx +++ b/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx @@ -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; diff --git a/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx index 623d452..945a872 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx @@ -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); @@ -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 ( -
+
{ if (e.target === e.currentTarget) onClose(); }} + > {/* Backdrop */} -
+
{/* Modal panel */} -
+
e.stopPropagation()} + > {/* Header */} -
-

- LLM Provider Settings -

+
+ + Settings +
{/* Content */} -
- {/* Provider dropdown */} +
+ + {/* ── Transcription section ── */}
- +
+ Transcription +
+ +
+ Larger models are more accurate but slower to load. Server has large-v3 downloaded. +
- {/* Model name */} + {/* ── LLM section ── */}
- - 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" - /> -
+
+ AI Summary Provider +
- {/* API Key */} -
- - 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" - /> -
+
+
+ + +
- {/* Note */} -

- API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server. -

+
+ + setForm({ ...form, model: e.target.value })} + placeholder={ + form.provider === 'ollama' ? 'e.g. gemma4:e4b, llama3.2' : + form.provider === 'anthropic' ? 'e.g. claude-sonnet-4-6' : + form.provider === 'google' ? 'e.g. gemini-2.0-flash' : + 'e.g. gpt-4o, gpt-4o-mini' + } + style={fieldStyle} + /> +
+ + {form.provider === 'ollama' && ( +
+ + setForm({ ...form, baseUrl: e.target.value })} + placeholder="https://ollama.kevlarai.com" + style={fieldStyle} + /> +
+ )} + +
+ + setForm({ ...form, apiKey: e.target.value })} + placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : 'sk-...'} + style={fieldStyle} + /> +
+
+ +
+ API keys are stored in your browser only. +
+
{/* Save button */}