diff --git a/src/App.tsx b/src/App.tsx index 36884a6..5921c17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,31 +4,23 @@ import { theme } from './services/themeService'; import { AuthProvider } from './contexts/AuthContext'; import { TLDrawProvider } from './contexts/TLDrawContext'; import { UserProvider } from './contexts/UserContext'; -import { NeoUserProvider } from './contexts/NeoUserContext'; -import { NeoInstituteProvider } from './contexts/NeoInstituteContext'; import AppRoutes from './AppRoutes'; import React from 'react'; -// Wrap the entire app in a memo to prevent unnecessary re-renders const App = React.memo(() => ( - - - - - - - + + + )); -// Add display name for better debugging App.displayName = import.meta.env.VITE_APP_NAME; -export default App; \ No newline at end of file +export default App; 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 b6e89ac..bbf2a6b 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -4,12 +4,12 @@ import { Session, User } from '@supabase/supabase-js'; import { CCUser, CCUserMetadata, authService } from '../services/auth/authService'; import { logger } from '../debugConfig'; import { supabase } from '../supabaseClient'; -import { DatabaseNameService } from '../services/graph/databaseNameService'; import { storageService, StorageKeys } from '../services/auth/localStorageService'; export interface AuthContextType { user: CCUser | null; user_role: string | null; + accessToken: string | null; loading: boolean; error: Error | null; signIn: (email: string, password: string) => Promise; @@ -20,6 +20,7 @@ export interface AuthContextType { export const AuthContext = createContext({ user: null, user_role: null, + accessToken: null, loading: true, error: null, signIn: async () => {}, @@ -31,9 +32,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const [user, setUser] = useState(null); const [user_role, setUserRole] = useState(null); - const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires + const [accessToken, setAccessToken] = useState(null); + 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); @@ -52,20 +56,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername; const userType = (metadata.user_type || 'email_teacher').trim(); - const storedUserDb = DatabaseNameService.getStoredUserDatabase(); - const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase(); - - const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(userType || 'standard', supabaseUser.id); - const schoolDbName = storedSchoolDb || ''; - const resolvedUser: CCUser = { id: supabaseUser.id, email: supabaseUser.email, user_type: userType, username: baseUsername, display_name: baseDisplayName, - user_db_name: userDbName, - school_db_name: schoolDbName, + school_id: null, created_at: supabaseUser.created_at, updated_at: supabaseUser.updated_at }; @@ -74,53 +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); - } catch (buildError) { - logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); - setUser(null); - setUserRole(null); - setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); - } - } else { + 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); - setLoading(false); - break; + setAccessToken(null); } - 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 { @@ -140,6 +166,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { user: resolvedUser, role } = await buildUserFromSupabase(data.user); setUser(resolvedUser); setUserRole(role); + setAccessToken(data.session?.access_token ?? null); } } catch (error) { logger.error('auth-context', '❌ Sign in failed', { error }); @@ -173,6 +200,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { value={{ user, user_role, + accessToken, loading, error, signIn, 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/contexts/UserContext.tsx b/src/contexts/UserContext.tsx index ecfec29..1ff8558 100644 --- a/src/contexts/UserContext.tsx +++ b/src/contexts/UserContext.tsx @@ -4,8 +4,6 @@ import { supabase } from '../supabaseClient'; import { logger } from '../debugConfig'; import { CCUser, CCUserMetadata } from '../services/auth/authService'; import { UserPreferences } from '../services/auth/profileService'; -import { DatabaseNameService } from '../services/graph/databaseNameService'; -import { provisionUser } from '../services/provisioningService'; import { storageService, StorageKeys } from '../services/auth/localStorageService'; export interface UserContextType { @@ -112,33 +110,23 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { let profileRow: Record | null = null; - // Fast-path: build profile from auth metadata + localStorage immediately. - // This clears the spinner before any network call so the page renders on refresh - // without waiting for the Supabase profiles query (~200ms). + // Fast-path: build profile from auth metadata immediately (no spinner on refresh). const fastMetadata = userInfo.user_metadata as CCUserMetadata; - const fastStoredUserDb = DatabaseNameService.getStoredUserDatabase(); - const fastStoredSchoolDb = DatabaseNameService.getStoredSchoolDatabase(); - const fastUserDb = fastStoredUserDb || DatabaseNameService.getUserPrivateDB(fastMetadata?.user_type || '', userInfo.id); const fastProfile: CCUser = { id: userInfo.id, email: userInfo.email, user_type: fastMetadata?.user_type || '', username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user', display_name: String(fastMetadata?.display_name || ''), - user_db_name: String(fastUserDb || ''), - school_db_name: String(fastStoredSchoolDb || ''), + school_id: null, created_at: userInfo.created_at, updated_at: userInfo.updated_at }; - DatabaseNameService.rememberDatabaseNames({ - userDbName: fastProfile.user_db_name, - schoolDbName: fastProfile.school_db_name - }); if (mountedRef.current && !isInitialized) { setProfile(fastProfile); setLoading(false); setIsInitialized(true); - logger.debug('user-context', '⚑ Fast-path: profile initialized from auth metadata, no spinner'); + logger.debug('user-context', '⚑ Fast-path: profile initialized from auth metadata'); } // Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name). @@ -172,80 +160,15 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { hasSchoolDb: !!profileRow?.school_db_name }); + const metadata = userInfo.user_metadata as CCUserMetadata; - logger.debug('user-context', 'πŸ”§ Step 7: Processing user metadata...', { - hasMetadata: !!metadata, - userType: metadata?.user_type - }); - let userDbName = profileRow?.user_db_name ?? null; - let schoolDbName = profileRow?.school_db_name ?? null; - const storedUserDb = DatabaseNameService.getStoredUserDatabase(); - const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase(); - - // Start provisioning in background (non-blocking) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const provisioningPromise = provisionUser(userInfo.id, authSession?.access_token ?? null) - .then(provisioned => { - if (provisioned) { - logger.debug('user-context', 'βœ… Provisioning completed in background', { - userDbName: provisioned.user_db_name, - workerDbName: provisioned.worker_db_name - }); - // Update localStorage with provisioned values - DatabaseNameService.rememberDatabaseNames({ - userDbName: provisioned.user_db_name, - schoolDbName: provisioned.worker_db_name || '' - }); - } - }) - .catch(provisionError => { - logger.warn('user-context', '⚠️ Background provisioning failed', { - userId: userInfo?.id, - provisionError: provisionError instanceof Error ? provisionError.message : String(provisionError) - }); - }); - - if (!userDbName && storedUserDb) { - userDbName = storedUserDb; - } - - if (!schoolDbName && storedSchoolDb) { - schoolDbName = storedSchoolDb; - } - - logger.debug('user-context', 'ℹ️ Database name resolution', { - userDbName, - schoolDbName - }); - - if (!userDbName) { - userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', userInfo.id); - } - - if (!schoolDbName) { - schoolDbName = ''; - } - - DatabaseNameService.rememberDatabaseNames({ - userDbName: String(userDbName || ''), - schoolDbName: String(schoolDbName || '') - }); - - logger.debug('user-context', 'πŸ”§ Creating user profile object...', { - userId: userInfo.id, - userDbName, - schoolDbName, - userType: metadata.user_type - }); - const userProfile: CCUser = { id: userInfo.id, email: userInfo.email, user_type: metadata.user_type || '', username: metadata.username || '', display_name: String(metadata.display_name || ''), - user_db_name: String(userDbName || ''), - school_db_name: String(schoolDbName || ''), + school_id: (profileRow?.school_id as string) ?? null, created_at: userInfo.created_at, updated_at: userInfo.updated_at }; @@ -268,9 +191,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { logger.debug('user-context', 'βœ… User profile loaded', { userId: userProfile.id, userType: userProfile.user_type, - username: userProfile.username, - userDbName: userProfile.user_db_name, - schoolDbName: userProfile.school_db_name + username: userProfile.username }); setError(null); } catch (error) { @@ -295,22 +216,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { user_type: metadata?.user_type || 'email_teacher', username: metadata?.username || userInfo.email?.split('@')[0] || 'user', display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User', - user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id), - school_db_name: '', + school_id: null, created_at: userInfo.created_at, updated_at: userInfo.updated_at }; - - DatabaseNameService.rememberDatabaseNames({ - userDbName: fallbackProfile.user_db_name, - schoolDbName: fallbackProfile.school_db_name - }); - setProfile(fallbackProfile); logger.debug('user-context', 'βœ… Fallback profile created', { userId: fallbackProfile.id, - userType: fallbackProfile.user_type, - userDbName: fallbackProfile.user_db_name + userType: fallbackProfile.user_type }); } else { setProfile(null); diff --git a/src/hooks/useDeviceContext.ts b/src/hooks/useDeviceContext.ts new file mode 100644 index 0000000..0863a09 --- /dev/null +++ b/src/hooks/useDeviceContext.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from 'react'; + +export type DeviceType = 'desktop' | 'tablet' | 'phone' | 'iwb'; + +function detectDeviceType(): DeviceType { + const width = window.innerWidth; + const touchPoints = navigator.maxTouchPoints ?? 0; + const hasTouch = touchPoints > 0 || 'ontouchstart' in window; + + if (width >= 1280 && !hasTouch) return 'desktop'; + if (width >= 768 && hasTouch) return 'tablet'; + if (width < 768) return 'phone'; + return 'desktop'; +} + +const STORAGE_KEY = 'cc_device_type'; + +export function useDeviceContext() { + const [deviceType, setDeviceTypeState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY) as DeviceType | null; + if (stored && ['desktop', 'tablet', 'phone', 'iwb'].includes(stored)) return stored; + return detectDeviceType(); + }); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, deviceType); + }, [deviceType]); + + const setDeviceType = (type: DeviceType) => { + setDeviceTypeState(type); + }; + + const isTouch = deviceType === 'tablet' || deviceType === 'phone'; + const isMobileLayout = deviceType === 'phone'; + + return { deviceType, setDeviceType, isTouch, isMobileLayout }; +} diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx index 12bcf6a..b851d94 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; import { SelectChangeEvent } from '@mui/material/Select'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type Manifest = { bucket: string; @@ -41,6 +41,7 @@ export const CCBundleViewer: React.FC<{ currentPage?: number; combinedBundles?: Array<{ id: string }>; }> = ({ fileId, bundleId, currentPage, combinedBundles }) => { + const { accessToken } = useAuth(); const [manifest, setManifest] = useState(null); const [combinedManifests, setCombinedManifests] = useState(null); const [mode, setMode] = useState('markdown_full'); @@ -53,9 +54,9 @@ export const CCBundleViewer: React.FC<{ const API_BASE_FALLBACK = 'http://127.0.0.1:8080'; const proxyUrl = useCallback(async (bucket: string, relPath: string) => { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`; - }, [API_BASE]); + }, [API_BASE, accessToken]); const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => { if (!s || typeof s !== 'string') return s || ''; @@ -222,7 +223,7 @@ export const CCBundleViewer: React.FC<{ setManifest(null); if (combinedBundles && combinedBundles.length > 0) { try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const ms: Manifest[] = []; for (const b of combinedBundles) { const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } }); @@ -239,7 +240,7 @@ export const CCBundleViewer: React.FC<{ } if (!bundleId) return; try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(await res.text()); const rawManifest: Manifest = await res.json(); @@ -266,7 +267,7 @@ export const CCBundleViewer: React.FC<{ let textParts: string[] = []; let jsonParts: string[] = []; for (const m of combinedManifests) { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; let rel: string | undefined; if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full; else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full; @@ -348,7 +349,7 @@ export const CCBundleViewer: React.FC<{ relPath = rec?.path; } if (!relPath) { setContent(''); setLoading(false); return; } - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const url = await proxyUrl(bucket, relPath); let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(await res.text()); diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx index faaadf8..fa03301 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Box, CircularProgress, IconButton } from '@mui/material'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type Artefact = { id: string; type: string; rel_path: string; created_at: string }; @@ -43,6 +43,7 @@ export const CCDoclingViewer: React.FC<{ hideToolbar?: boolean; sectionRange?: { start: number; end: number }; }> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => { + const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [images, setImages] = useState>([]); @@ -184,7 +185,7 @@ export const CCDoclingViewer: React.FC<{ const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); try { const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (mRes.ok) { const m: PageImagesManifest = await mRes.json(); @@ -199,7 +200,7 @@ export const CCDoclingViewer: React.FC<{ // Legacy: Load artefacts for file to find docling JSON artefacts const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!artefactsRes.ok) throw new Error(await artefactsRes.text()); const artefacts: Artefact[] = await artefactsRes.json(); @@ -216,7 +217,7 @@ export const CCDoclingViewer: React.FC<{ // Download artefact JSON via backend (service-role) to avoid RLS issues const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!jsonRes.ok) throw new Error(await jsonRes.text()); const doc: DoclingJson = await jsonRes.json(); @@ -267,7 +268,7 @@ export const CCDoclingViewer: React.FC<{ setPageObjectUrl(cached); return; } - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok && manifest) { // Fallback to thumbnail if the full image is not accessible yet @@ -369,6 +370,7 @@ export const CCDoclingViewer: React.FC<{ export default CCDoclingViewer; const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => { + const { accessToken } = useAuth(); const [blobUrl, setBlobUrl] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -376,7 +378,7 @@ const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => { let revoked: string | null = null; const load = async () => { try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx index dd61f28..fd0f1f0 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx @@ -6,7 +6,7 @@ import { SelectChangeEvent } from '@mui/material/Select'; import { CCDoclingViewer } from './CCDoclingViewer.tsx'; import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx'; import CCBundleViewer from './CCBundleViewer.tsx'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type CanonicalDoclingConfig = { pipeline: 'standard' | 'vlm' | 'asr'; @@ -46,6 +46,7 @@ export const CCDocumentIntelligence: React.FC = () => { const { fileId } = useParams<{ fileId: string }>(); const validFileId = useMemo(() => fileId || '', [fileId]); + const { accessToken } = useAuth(); const [page, setPage] = useState(1); const [outlineOptions, setOutlineOptions] = useState>([]); const [profile, setProfile] = useState('default'); @@ -148,7 +149,7 @@ export const CCDocumentIntelligence: React.FC = () => { const loadBundles = async () => { if (!validFileId) return; const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) return; const arts: Artefact[] = await res.json(); @@ -225,14 +226,14 @@ export const CCDocumentIntelligence: React.FC = () => { const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); try { const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!artsRes.ok) return; const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json(); const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy'); if (!outlineArt) return; const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!jsonRes.ok) return; const doc = await jsonRes.json(); @@ -242,7 +243,7 @@ export const CCDocumentIntelligence: React.FC = () => { const splitArt = arts.find(a => a.type === 'split_map_json'); if (splitArt) { const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (smRes.ok) { const sm = await smRes.json(); @@ -486,7 +487,7 @@ export const CCDocumentIntelligence: React.FC = () => { try { setBusy(true); const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const body: CanonicalDoclingRequest = { use_split_map: selectedSectionId === 'full' ? autoSplit : false, config: { diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx index 20b5bdb..aa8295b 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx @@ -11,7 +11,8 @@ import Schedule from '@mui/icons-material/Schedule'; import Visibility from '@mui/icons-material/Visibility'; import Psychology from '@mui/icons-material/Psychology'; import Overview from '@mui/icons-material/Home'; -import { supabase } from '../../../supabaseClient'; +import CalendarViewMonth from '@mui/icons-material/CalendarViewMonth'; +import { useAuth } from '../../../contexts/AuthContext'; // Types type PageImagesManifest = { @@ -66,6 +67,7 @@ export const CCEnhancedFilePanel: React.FC = ({ fileId, selectedPage, onSelectPage, currentSection }) => { // State + const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [manifest, setManifest] = useState(null); @@ -94,7 +96,7 @@ export const CCEnhancedFilePanel: React.FC = ({ setError(null); try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; // Load page images manifest const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { @@ -198,7 +200,7 @@ export const CCEnhancedFilePanel: React.FC = ({ try { const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`; - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) return undefined; diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx index 1706d56..8d7b509 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx @@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type PageImagesManifest = { version: number; @@ -54,6 +54,7 @@ export const CCFileDetailPanel: React.FC<{ selectedPage: number; onSelectPage: (p: number) => void; }> = ({ fileId, selectedPage, onSelectPage }) => { + const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [manifest, setManifest] = useState(null); @@ -73,7 +74,7 @@ export const CCFileDetailPanel: React.FC<{ setError(null); try { const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!mRes.ok) throw new Error(await mRes.text()); const m: PageImagesManifest = await mRes.json(); @@ -82,14 +83,14 @@ export const CCFileDetailPanel: React.FC<{ // Try to load outline structure artefact (for grouping only) try { const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (artsRes.ok) { const arts: Array<{ id: string; type: string }> = await artsRes.json(); const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy'); if (outlineArt) { const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (jsonRes.ok) { const outJson = await jsonRes.json(); @@ -118,7 +119,7 @@ export const CCFileDetailPanel: React.FC<{ const pg = manifest.page_images[idx]; if (!pg) return undefined; const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`; - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok) return undefined; const blob = await resp.blob(); @@ -150,7 +151,7 @@ export const CCFileDetailPanel: React.FC<{ { try { setShowAdmin(true); - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } }); const data = await res.json(); setAdminData(data); diff --git a/src/pages/tldraw/singlePlayerPage.tsx b/src/pages/tldraw/singlePlayerPage.tsx index 7ccf24c..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'); @@ -122,7 +123,7 @@ export default function SinglePlayerPage() { const nodeStoragePath = getNodeStoragePath(context.node); if (nodeStoragePath) { logger.debug('single-player-page', 'πŸ“₯ Loading snapshot from database', { - dbName: user.user_db_name, + dbName: null, node: context.node, node_storage_path: nodeStoragePath, user_type: user.user_type, @@ -131,12 +132,14 @@ export default function SinglePlayerPage() { await NavigationSnapshotService.loadNodeSnapshotFromDatabase( nodeStoragePath, - user.user_db_name, + 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/auth/authService.ts b/src/services/auth/authService.ts index abeb77f..7cc49cc 100644 --- a/src/services/auth/authService.ts +++ b/src/services/auth/authService.ts @@ -3,7 +3,6 @@ import { TLUserPreferences } from '@tldraw/tldraw'; import { supabase } from '../../supabaseClient'; import { storageService, StorageKeys } from './localStorageService'; import { logger } from '../../debugConfig'; -import { DatabaseNameService } from '../graph/databaseNameService'; export interface CCUser { id: string; @@ -11,8 +10,7 @@ export interface CCUser { user_type: string; username: string; display_name: string; - user_db_name: string; - school_db_name: string; + school_id?: string | null; created_at?: string; updated_at?: string; } @@ -44,28 +42,13 @@ export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser { // Default to student if no user type specified const userType = metadata.user_type || 'student'; - const storedUserDb = DatabaseNameService.getStoredUserDatabase(); - const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase(); - - const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB( - userType, - user.id - ); - const schoolDbName = metadata.school_db_name || metadata.worker_db_name || storedSchoolDb || ''; - - DatabaseNameService.rememberDatabaseNames({ - userDbName, - schoolDbName - }); - return { id: user.id, email: user.email, user_type: userType, - username: username, + username, display_name: displayName, - user_db_name: userDbName, - school_db_name: schoolDbName, + school_id: null, created_at: user.created_at, updated_at: user.updated_at, }; diff --git a/src/services/auth/registrationService.ts b/src/services/auth/registrationService.ts index b4c7871..e704087 100644 --- a/src/services/auth/registrationService.ts +++ b/src/services/auth/registrationService.ts @@ -6,7 +6,6 @@ import { RegistrationResponse } from '../../services/auth/authService'; import { storageService, StorageKeys } from './localStorageService'; import { logger } from '../../debugConfig'; import { provisionUser } from '../provisioningService'; -import { DatabaseNameService } from '../graph/databaseNameService'; const REGISTRATION_SERVICE = 'registration-service'; @@ -87,14 +86,6 @@ export class RegistrationService { try { const provisioned = await provisionUser(ccUser.id, provisioningToken); if (provisioned) { - ccUser.user_db_name = provisioned.user_db_name; - if (provisioned.worker_db_name) { - ccUser.school_db_name = provisioned.worker_db_name; - } - DatabaseNameService.rememberDatabaseNames({ - userDbName: ccUser.user_db_name, - schoolDbName: ccUser.school_db_name - }); logger.info(REGISTRATION_SERVICE, 'βœ… Provisioning successful', { userId: ccUser.id, userDbName: provisioned.user_db_name, @@ -110,11 +101,6 @@ export class RegistrationService { }); } - DatabaseNameService.rememberDatabaseNames({ - userDbName: ccUser.user_db_name, - schoolDbName: ccUser.school_db_name - }); - return { user: ccUser, accessToken: authData.session?.access_token || null, diff --git a/src/services/tldraw/snapshotService.ts b/src/services/tldraw/snapshotService.ts index 1a9238a..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,248 +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.user_db_name; - - logger.debug('snapshot-service', 'πŸ’Ύ Saving snapshot', { - nodePath, - dbName, - userType: user.user_type, - username: user.username - }); - - await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store); - - logger.debug('snapshot-service', 'βœ… Saved navigation snapshot', { - nodePath, - storeId: this.store.id - }); + 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 { @@ -307,137 +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.user_db_name; - - logger.debug('snapshot-service', 'πŸ“₯ Loading snapshot', { - nodePath: node.node_storage_path, - dbName, - userType: user.user_type, - username: user.username - }); - await NavigationSnapshotService.loadNodeSnapshotFromDatabase( 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 { @@ -447,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 bf310d8..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; @@ -23,6 +22,12 @@ export interface TranscriptionSession { segment_count: number; } +export interface ServerSegment { + text: string; + start: number; + end: number; +} + export interface TimetablePeriod { period_id: string | null; event_type: string | null; @@ -35,6 +40,8 @@ export interface LLMConfig { provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google'; model: string; apiKey: string; + baseUrl?: string; // for Ollama: e.g. https://ollama.kevlarai.com + whisperModel?: string; // faster-whisper model size sent to WhisperLive } export type ExportFormat = 'srt' | 'txt' | 'json'; @@ -72,6 +79,8 @@ function loadLLMConfig(): LLMConfig { provider: 'openai', model: '', apiKey: '', + baseUrl: '', + whisperModel: 'large-v3', }; } @@ -90,8 +99,9 @@ interface TranscriptionState { activeSession: TranscriptionSession | null; // Live feed - completedSegments: TranscriptionSegment[]; - currentSegment: TranscriptionSegment | null; + completedSegments: TranscriptionSegment[]; // segments that scrolled off the server window (archived) + serverWindow: ServerSegment[]; // the current server-provided segment window (last N) + currentSegment: TranscriptionSegment | null; // the live (last) segment if still being refined // Canvas event buffer (flushed to API every 5s) pendingCanvasEvents: any[]; @@ -119,9 +129,15 @@ interface TranscriptionState { keywordWatches: KeywordWatch[]; keywordMatches: KeywordMatch[]; + // Auth (set by panel via setAuthInfo after SIGNED_IN) + _accessToken: string | null; + _userId: string | null; + setAuthInfo: (token: string | null, userId: string | null) => void; + // Actions startSession: (timetableTag?: TimetablePeriod) => Promise; stopSession: () => Promise; + updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void; saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise; resetSession: () => void; tickElapsed: () => void; @@ -151,409 +167,577 @@ interface TranscriptionState { clearKeywordMatches: () => void; } -export const useTranscriptionStore = create((set, get) => ({ - isRecording: false, - isConnecting: false, - activeSession: null, - completedSegments: [], - 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 }); - }, - - startSession: async (timetableTag?: TimetablePeriod) => { - set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); - - // Create session in Supabase - try { - const user = await supabase.auth.getUser(); - if (!user.data.user) { - console.error('No authenticated user'); - return; - } - - const sessionData = { - user_id: user.data.user.id, - title: timetableTag?.event_label || 'Untitled Session', - canvas_type: 'teaching-canvas', - timetable_period_id: timetableTag?.period_id || null, - timetable_event_type: timetableTag?.event_type || null, - timetable_event_label: timetableTag?.event_label || null, - auto_tagged: !!timetableTag, - }; - - const { data, error } = await supabase - .from('transcription_sessions') - .insert(sessionData) - .select() - .single(); - - if (error) { - console.error('Failed to create session:', error); - return; - } - - set({ activeSession: data }); - } catch (error) { - console.error('Error starting session:', error); - } - }, - - stopSession: async () => { - const { activeSession, completedSegments } = get(); - - if (activeSession) { - try { - await supabase - .from('transcription_sessions') - .update({ - ended_at: new Date().toISOString(), - word_count: get().wordCount, - segment_count: completedSegments.length, - }) - .eq('id', activeSession.id); - } catch (error) { - console.error('Failed to end session:', error); - } - } - - set({ - isRecording: false, - isConnecting: false, - activeSession: null, +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; + }; - saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { - const { completedSegments, currentSegment, activeSession, wordCount } = get(); + return { + 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 }); + + try { + const { _userId: userId } = get(); + if (!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 = 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); + } + }, + + stopSession: async () => { + const { activeSession, currentSegment, completedSegments } = get(); + + // 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)); + } + } + } + + const finalWordCount = newCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 + ); + + if (activeSession) { + try { + await pgFetch('PATCH', 'transcription_sessions', { + query: `id=eq.${activeSession.id}`, + body: { + ended_at: new Date().toISOString(), + word_count: finalWordCount, + segment_count: newCompleted.length, + }, + }); + } catch (error) { + console.error('Failed to end session:', error); + } + } + + set({ + isRecording: false, + isConnecting: false, + activeSession: null, + completedSegments: newCompleted, + serverWindow: [], + currentSegment: null, + wordCount: finalWordCount, + }); + }, + + updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => { + const { completedSegments, activeSession } = get(); + + if (segments.length === 0) return; + + // The server marks every finalized segment with completed=true and the live + // one with completed=false. Rather than relying on window-scroll detection + // (which can miss segments when the server creates several at once), we + // directly merge every completed segment from this message into the store. + // This guarantees no gaps: any segment the server says is complete is captured + // immediately, regardless of how many were created since the last message. + const serverCompleted = isLastLive ? segments.slice(0, -1) : segments; + + let newCompleted = [...completedSegments]; + const toSave: Array<{ seg: ServerSegment; idx: number }> = []; + + for (const seg of serverCompleted) { + if (!seg.text.trim()) continue; + const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5); + if (existingIdx >= 0) { + // Server refined an existing segment β€” update text and end time in place. + newCompleted[existingIdx] = { + ...newCompleted[existingIdx], + text: seg.text, + end: seg.end, + }; + } else { + const newIdx = newCompleted.length; + newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end }); + toSave.push({ seg, idx: newIdx }); + } + } + + // Keep sorted by start time so display order is always correct. + newCompleted.sort((a, b) => a.start - b.start); + + // Persist and keyword-check only truly new segments. + if (toSave.length > 0) { + const elapsed = get().elapsedSeconds; + for (const { seg, idx } of toSave) { + if (activeSession) { + pgFetch('POST', 'transcription_segments', { + body: { + session_id: activeSession.id, + sequence_index: idx, + text: seg.text, + start_seconds: seg.start, + end_seconds: seg.end, + is_final: true, + }, + }).catch(err => console.error('Failed to save segment:', err)); + } + get().checkSegmentForKeywords(seg.text, elapsed); + } + } + + const lastSeg = segments[segments.length - 1]; + const newCurrentSegment: TranscriptionSegment | null = isLastLive + ? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end } + : null; - if (isFinal) { - // Final segment β€” append the finalized text directly (not currentSegment, which - // may lag behind or duplicate when WhisperLive re-sends the full segments array). - const newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }]; const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); set({ + serverWindow: segments, completedSegments: newCompleted, - currentSegment: null, + currentSegment: newCurrentSegment, wordCount: newWordCount, }); + }, - // Save to Supabase if session is active - if (activeSession) { - try { - const sequenceIndex = newCompleted.length - 1; - await supabase.from('transcription_segments').insert({ - session_id: activeSession.id, - sequence_index: sequenceIndex, - text: 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 - set({ currentSegment: { text, isFinal: false, ...metadata } }); - } - }, - resetSession: () => { - set({ - isRecording: false, - isConnecting: false, - completedSegments: [], - currentSegment: null, - wordCount: 0, - elapsedSeconds: 0, - activeSession: null, - pendingCanvasEvents: [], - timetableContext: null, - keywordMatches: [], - }); - }, + const newWordCount = newCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 + ); - tickElapsed: () => { - set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 })); - }, + set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); - addCanvasEvent: (event) => { - set((state) => ({ - pendingCanvasEvents: [...state.pendingCanvasEvents, event], - })); - }, - - flushCanvasEvents: async () => { - const { pendingCanvasEvents, activeSession } = get(); - - if (pendingCanvasEvents.length === 0) return; - - const eventsToFlush = [...pendingCanvasEvents]; - - try { - for (const event of eventsToFlush) { - await supabase.from('canvas_events').insert({ - session_id: activeSession?.id || null, - user_id: (await supabase.auth.getUser()).data.user?.id || '', - timestamp: new Date().toISOString(), - session_elapsed_seconds: event.sessionElapsedSeconds || null, - event_type: event.eventType, - event_payload: event.payload || {}, - canvas_snapshot_url: event.snapshotUrl || null, - tldraw_page_id: event.pageId || null, - tldraw_shape_ids: event.shapeIds || null, - }); - } - - set({ pendingCanvasEvents: [] }); - } catch (error) { - console.error('Failed to flush canvas events:', error); - } - }, - - loadSessions: async (): Promise => { - try { - const user = await supabase.auth.getUser(); - if (!user.data.user) return []; - - const { data, error } = await supabase - .from('transcription_sessions') - .select('*') - .eq('user_id', user.data.user.id) - .order('started_at', { ascending: false }) - .limit(50); - - if (error) { - console.error('Failed to load sessions:', error); - return []; - } - - return 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 { data: { session } } = await supabase.auth.getSession(); - if (!session?.access_token) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { - headers: { 'Authorization': `Bearer ${session.access_token}` }, - }); - if (!response.ok) return; - const watches = await response.json(); - set({ keywordWatches: watches }); - } catch (error) { - console.error('Failed to load keyword watches:', error); - } - }, - - addKeywordWatch: async (keyword: string) => { - try { - const { data: { session } } = await supabase.auth.getSession(); - if (!session?.access_token) return; - const user = await supabase.auth.getUser(); - if (!user.data.user) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session.access_token}`, - }, - body: JSON.stringify({ - user_id: user.data.user.id, - keyword: keyword.trim(), - match_type: 'contains', - action: 'alert', - }), - }); - if (!response.ok) return; - const newWatch = await response.json(); - set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] })); - } catch (error) { - console.error('Failed to add keyword watch:', error); - } - }, - - deleteKeywordWatch: async (watchId: string) => { - try { - const { data: { session } } = await supabase.auth.getSession(); - if (!session?.access_token) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${session.access_token}` }, - }); - set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); - } catch (error) { - console.error('Failed to delete keyword watch:', error); - } - }, - - checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => { - const { keywordWatches, activeSession } = get(); - if (keywordWatches.length === 0) return; - - const lowerText = text.toLowerCase(); - const matches: KeywordMatch[] = []; - - for (const watch of keywordWatches) { - if (!watch.is_active) continue; - const lowerKeyword = watch.keyword.toLowerCase(); - const matched = - watch.match_type === 'exact' - ? lowerText === lowerKeyword - : watch.match_type === 'starts_with' - ? lowerText.startsWith(lowerKeyword) - : lowerText.includes(lowerKeyword); - - if (matched) { - matches.push({ - keyword: watch.keyword, - watch_id: watch.id, - segment_text: text, - elapsed_seconds: elapsedSeconds, - matched_at: new Date().toISOString(), - }); - - if (activeSession) { + if (isNew && activeSession) { try { - const { data: { session } } = await supabase.auth.getSession(); - 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', - ...(session?.access_token ? { 'Authorization': `Bearer ${session.access_token}` } : {}), - }, - 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/BasePanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx index decb5f1..08a6b25 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx @@ -34,6 +34,7 @@ import { CCYoutubePanel } from './CCYoutubePanel'; import { CCGraphPanel } from './CCGraphPanel'; import { CCExamMarkerPanel } from './CCExamMarkerPanel'; import { CCSearchPanel } from './CCSearchPanel' +import { CCGraphNavPanel } from './navigation/CCGraphNavPanel' import { CCTranscriptionPanel } from './CCTranscriptionPanel' import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles'; import './panel.css'; @@ -145,7 +146,6 @@ export const BasePanel: React.FC = ({ return createTheme({ palette: { mode, - divider: 'var(--color-divider)', }, }); }, [tldrawPreferences?.colorScheme, prefersDarkMode]); @@ -281,6 +281,8 @@ export const BasePanel: React.FC = ({ return ; case 'search': return ; + case 'navigation': + return ; default: return null; } @@ -386,9 +388,11 @@ export const BasePanel: React.FC = ({ +
{renderCurrentPanel()}
+
)} diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCCabinetsPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCCabinetsPanel.tsx index f4406dd..1de467e 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCCabinetsPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCCabinetsPanel.tsx @@ -1,17 +1,18 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ThemeProvider, createTheme, useMediaQuery, Box, Grid, Card, CardContent, CardActions, Typography, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, styled } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { useTLDraw } from '../../../../../contexts/TLDrawContext'; -import { supabase } from '../../../../../supabaseClient'; +import { useAuth } from '../../../../../contexts/AuthContext'; type Cabinet = { id: string; name: string }; const Toolbar = styled('div')(() => ({ display: 'flex', gap: '8px', marginBottom: '8px' })); export const CCCabinetsPanel: React.FC = () => { - const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string }; + const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; + const { user: authUser, accessToken } = useAuth(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [cabinets, setCabinets] = useState([]); const [createOpen, setCreateOpen] = useState(false); @@ -28,27 +29,29 @@ export const CCCabinetsPanel: React.FC = () => { const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record } | undefined; - const apiFetch = async (url: string, init?: RequestInitLite) => { + const apiFetch = useCallback(async (url: string, init?: RequestInitLite) => { const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; - const { data: { session } } = await supabase.auth.getSession(); - const bearer = session?.access_token || authToken || ''; const res = await fetch(fullUrl, { ...init, headers: { - 'Authorization': `Bearer ${bearer}`, + 'Authorization': `Bearer ${accessToken || ''}`, ...(init?.headers || {}) } }); if (!res.ok) throw new Error(await res.text()); return res.json(); - }; + }, [accessToken, API_BASE]); - const loadCabinets = async () => { + const loadCabinets = useCallback(async () => { const data = await apiFetch('/database/cabinets'); setCabinets([...(data.owned || []), ...(data.shared || [])]); - }; + }, [apiFetch]); - useEffect(() => { loadCabinets(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, []); + useEffect(() => { + if (authUser?.id) { + loadCabinets(); + } + }, [loadCabinets, authUser?.id]); const handleCreate = async () => { if (!newName.trim()) return; diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx index 881cc4b..fb6b830 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx @@ -40,7 +40,7 @@ import ImageIcon from '@mui/icons-material/Image'; import DescriptionIcon from '@mui/icons-material/Description'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import { useTLDraw } from '../../../../../contexts/TLDrawContext'; -import { supabase } from '../../../../../supabaseClient'; +import { useAuth } from '../../../../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; import { calculateDirectoryStats, @@ -92,7 +92,8 @@ interface FileListResponse { } export const CCFilesPanel: React.FC = () => { - const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string }; + const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; + const { user: authUser, accessToken } = useAuth(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [cabinets, setCabinets] = useState([]); const [selectedCabinet, setSelectedCabinet] = useState(''); @@ -115,6 +116,7 @@ export const CCFilesPanel: React.FC = () => { const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([ { id: null, name: 'Root' } ]); + const initialSelectionDone = useRef(false); // Directory upload state const [selectedFiles, setSelectedFiles] = useState([]); @@ -143,14 +145,14 @@ export const CCFilesPanel: React.FC = () => { const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => { const headers: HeadersInitLike = { - 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`, + 'Authorization': `Bearer ${accessToken || ''}`, ...(init?.headers || {}) }; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; const res = await fetch(fullUrl, { ...(init || {}), headers }); if (!res.ok) throw new Error(await res.text()); return res.json(); - }, [authToken, API_BASE]); + }, [accessToken, API_BASE]); const loadCabinets = useCallback(async () => { setLoading(true); @@ -158,13 +160,16 @@ export const CCFilesPanel: React.FC = () => { const data = await apiFetch('/database/cabinets'); const all = [...(data.owned || []), ...(data.shared || [])]; setCabinets(all); - if (all.length && !selectedCabinet) setSelectedCabinet(all[0].id); + if (all.length && !initialSelectionDone.current) { + initialSelectionDone.current = true; + setSelectedCabinet(all[0].id); + } } catch (error) { console.error('Failed to load cabinets:', error); } finally { setLoading(false); } - }, [selectedCabinet, apiFetch]); + }, [apiFetch]); const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => { if (!cabinetId) return; @@ -203,8 +208,11 @@ export const CCFilesPanel: React.FC = () => { }, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]); useEffect(() => { - loadCabinets(); - }, [loadCabinets]); + if (authUser?.id) { + initialSelectionDone.current = false; + loadCabinets(); + } + }, [loadCabinets, authUser?.id]); // Main loading effect - handles pagination, sorting, cabinet changes, directory navigation useEffect(() => { diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx index 842c1ad..15ab2a3 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx @@ -35,7 +35,7 @@ import DescriptionIcon from '@mui/icons-material/Description'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import { useTLDraw } from '../../../../../contexts/TLDrawContext'; -import { supabase } from '../../../../../supabaseClient'; +import { useAuth } from '../../../../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; import { pickDirectory, @@ -75,7 +75,8 @@ interface UploadProgress { } export const CCFilesPanelEnhanced: React.FC = () => { - const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string }; + const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; + const { user: authUser, accessToken } = useAuth(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [cabinets, setCabinets] = useState([]); const [selectedCabinet, setSelectedCabinet] = useState(''); @@ -109,7 +110,7 @@ export const CCFilesPanelEnhanced: React.FC = () => { const apiFetch = async (url: string, init?: RequestInitLike) => { const headers: HeadersInitLike = { - 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`, + 'Authorization': `Bearer ${accessToken || ''}`, ...(init?.headers || {}) }; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; @@ -140,7 +141,7 @@ export const CCFilesPanelEnhanced: React.FC = () => { } }; - useEffect(() => { loadCabinets(); }, []); + useEffect(() => { if (authUser?.id) { loadCabinets(); } }, [authUser?.id]); useEffect(() => { if (selectedCabinet) loadFiles(selectedCabinet); }, [selectedCabinet]); const handleSingleUpload = async (e: React.ChangeEvent) => { diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx index 6cd8ef2..300b8a7 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; +import { useAuth } from "../../../../../contexts/AuthContext"; import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material"; -import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch } from "../../../../../stores/transcriptionStore"; +import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch, ServerSegment } from "../../../../../stores/transcriptionStore"; import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService"; import { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger"; import LLMConfigModal from "./LLMConfigModal"; @@ -17,6 +18,26 @@ const formatDateTime = (isoString: string): string => { return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; +const formatSrtTime = (seconds: number): string => { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + const ms = Math.round((seconds % 1) * 1000); + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`; +}; + +const downloadBlob = (content: string, filename: string, mimeType: string) => { + const blob = new Blob([content], { type: mimeType }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); +}; + type TabType = "live" | "sessions" | "keywords"; const SUMMARY_TYPES = [ @@ -31,6 +52,7 @@ export const CCTranscriptionPanel: React.FC = () => { const { isRecording, completedSegments, + serverWindow, currentSegment, wordCount, elapsedSeconds, @@ -38,13 +60,14 @@ export const CCTranscriptionPanel: React.FC = () => { timetableContext, startSession, stopSession, - saveSegment, + updateServerWindow, resetSession, tickElapsed, addCanvasEvent, flushCanvasEvents, loadSessions, setTimetableContext, + setAuthInfo, llmConfig, summaryText, isGeneratingSummary, @@ -62,6 +85,7 @@ export const CCTranscriptionPanel: React.FC = () => { } = useTranscriptionStore(); const [activeTab, setActiveTab] = useState("live"); + const [viewMode, setViewMode] = useState<'segments' | 'transcript'>('segments'); const [sessions, setSessions] = useState([]); const [sessionName, setSessionName] = useState("Untitled Session"); const serviceRef = useRef(null); @@ -70,6 +94,7 @@ export const CCTranscriptionPanel: React.FC = () => { // Modal state const [showSettingsModal, setShowSettingsModal] = useState(false); + const { user: authUser, accessToken } = useAuth(); const [showSummaryModal, setShowSummaryModal] = useState(false); const [summaryType, setSummaryType] = useState('full_lesson'); @@ -77,17 +102,25 @@ export const CCTranscriptionPanel: React.FC = () => { const [newKeyword, setNewKeyword] = useState(''); const [isAddingKeyword, setIsAddingKeyword] = useState(false); - // Load sessions and keyword watches on mount + // Sync access token into Zustand store so all store actions can use it without getSession() useEffect(() => { - loadSessions().then(setSessions); - loadKeywordWatches(); - }, []); + setAuthInfo(accessToken, authUser?.id ?? null); + }, [accessToken, authUser?.id, setAuthInfo]); + + // Load sessions when auth is confirmed (avoids GoTrueClient lock on mount) + useEffect(() => { + if (authUser?.id) { + loadSessions().then(setSessions); + loadKeywordWatches(); + } + }, [authUser?.id]); // Auto-detect timetable context on mount useEffect(() => { const detectTimetable = async () => { try { - const response = await fetch('http://192.168.0.64:8000/database/timetables/current-period'); + const apiBase = import.meta.env.VITE_API_URL || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBase}/database/timetables/current-period`); const data = await response.json(); if (data.period_id) { setTimetableContext(data as TimetablePeriod); @@ -127,14 +160,16 @@ export const CCTranscriptionPanel: React.FC = () => { try { await startSession(timetableContext || undefined); const service = new TranscriptionService(); - service.setTranscriptionCallback((text, isFinal, metadata) => { - saveSegment(text, isFinal, metadata); - if (isFinal) { - const { elapsedSeconds: elapsed } = useTranscriptionStore.getState(); - checkSegmentForKeywords(text, elapsed); - } + service.setServerSegmentsCallback((segs: ServerSegment[], isLastLive: boolean) => { + updateServerWindow(segs, isLastLive); }); - await service.startTranscription(); + service.setDisconnectCallback(() => { + console.warn('[CCTranscriptionPanel] WebSocket disconnected unexpectedly β€” resetting session'); + serviceRef.current = null; + stopSession(); + }); + const whisperModel = useTranscriptionStore.getState().llmConfig.whisperModel || 'large-v3'; + await service.startTranscription({ modelSize: whisperModel }); serviceRef.current = service; // Initialize canvas event logger if session was created @@ -176,16 +211,17 @@ export const CCTranscriptionPanel: React.FC = () => { setSessions(loaded); }; - // Generate summary handler + // Generate summary β€” calls LLM providers directly, no backend proxy needed const handleGenerateSummary = async () => { - if (!activeSession) { - setSummaryError("No active session to generate summary for."); + const config = useTranscriptionStore.getState().llmConfig; + const allSegs = completedSegments; + + if (allSegs.length === 0) { + setSummaryError("No transcription segments to summarise yet."); return; } - - const config = useTranscriptionStore.getState().llmConfig; - if (!config.apiKey) { - setSummaryError("Please configure your API key in Settings first."); + if (!config.model) { + setSummaryError("Please configure an LLM model in Settings first."); return; } @@ -193,30 +229,79 @@ export const CCTranscriptionPanel: React.FC = () => { setSummaryError(null); setShowSummaryModal(false); - try { - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/summaries`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - summary_type: summaryType, - provider: config.provider, - model: config.model, - api_key: config.apiKey, - }), - }); + const transcript = allSegs.map(s => s.text.trim()).filter(Boolean).join(' '); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`); + const promptMap: Record = { + full_lesson: `You are an assistant helping a teacher review a lesson. Below is the full transcript of a lesson. Write a concise summary covering the main topics taught, key activities, and any notable moments.\n\nTranscript:\n${transcript}`, + questions_asked: `Below is a classroom transcript. List all the questions that were asked during the lesson, formatted as a numbered list.\n\nTranscript:\n${transcript}`, + teaching_style: `Below is a classroom transcript. Briefly describe the teaching style observed: instructional approach, student engagement techniques, pacing, and tone.\n\nTranscript:\n${transcript}`, + key_moments: `Below is a classroom transcript. Identify the 3–5 most significant moments or turning points in the lesson.\n\nTranscript:\n${transcript}`, + segment: `Summarise the following classroom transcript in 2–3 sentences.\n\nTranscript:\n${transcript}`, + }; + + const prompt = promptMap[summaryType] || promptMap.full_lesson; + + try { + let summaryResult = ''; + + if (config.provider === 'ollama') { + const base = (config.baseUrl || 'https://ollama.kevlarai.com').replace(/\/$/, ''); + const headers: Record = { 'Content-Type': 'application/json' }; + if (config.apiKey && config.apiKey !== 'sk-dummy') headers['Authorization'] = `Bearer ${config.apiKey}`; + const res = await fetch(`${base}/api/chat`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: config.model, + messages: [{ role: 'user', content: prompt }], + stream: false, + }), + }); + if (!res.ok) throw new Error(`Ollama error ${res.status}: ${await res.text()}`); + const d = await res.json(); + summaryResult = d.message?.content ?? JSON.stringify(d); + + } else if (config.provider === 'openai' || config.provider === 'openrouter') { + const base = config.provider === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://api.openai.com/v1'; + const res = await fetch(`${base}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` }, + body: JSON.stringify({ model: config.model, messages: [{ role: 'user', content: prompt }] }), + }); + if (!res.ok) throw new Error(`${config.provider} error ${res.status}: ${await res.text()}`); + const d = await res.json(); + summaryResult = d.choices?.[0]?.message?.content ?? JSON.stringify(d); + + } else if (config.provider === 'anthropic') { + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': config.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ model: config.model, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }), + }); + if (!res.ok) throw new Error(`Anthropic error ${res.status}: ${await res.text()}`); + const d = await res.json(); + summaryResult = d.content?.[0]?.text ?? JSON.stringify(d); + + } else if (config.provider === 'google') { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }), + }); + if (!res.ok) throw new Error(`Google error ${res.status}: ${await res.text()}`); + const d = await res.json(); + summaryResult = d.candidates?.[0]?.content?.parts?.[0]?.text ?? JSON.stringify(d); + + } else { + throw new Error(`Unknown provider: ${config.provider}`); } - const data = await response.json(); - // The API returns the summary text in the response - const summary = data.summary || data.content || data.text || JSON.stringify(data); - setSummaryText(summary); + setSummaryText(summaryResult); } catch (error) { console.error('Failed to generate summary:', error); setSummaryError(error instanceof Error ? error.message : 'Failed to generate summary'); @@ -411,38 +496,49 @@ export const CCTranscriptionPanel: React.FC = () => { )} - {/* Export button */} - {activeSession && ( + {/* Export button β€” available whenever there are completed segments */} + {completedSegments.length > 0 && ( <>
- Export Session + Export ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''})
{(['srt', 'txt', 'json'] as const).map((format) => ( + ))}
- ))} +
- {currentSegment && ( -
- {currentSegment.text || "Listening..."} -
- )} + {(() => { + const allFinal = completedSegments; // already merged from server on every message + + if (viewMode === "segments") { + return ( + <> + {allFinal.map((seg, i) => ( +
+
+ {formatTime(Math.floor(seg.start))} β†’ {formatTime(Math.floor(seg.end))} +
+
+ {seg.text} +
+
+ ))} + + {currentSegment && ( +
+
+ {formatTime(Math.floor(currentSegment.start))} β†’ … +
+
+ {currentSegment.text || "Listening…"} +
+
+ )} + + ); + } + + // Transcript view β€” single joined box + separate live segment + const joinedText = allFinal.map(s => s.text.trim()).filter(Boolean).join(" "); + return ( + <> + {(joinedText || !currentSegment) && ( +
+ {joinedText || No completed segments yet.} +
+ )} + + {currentSegment && ( +
+
+ {formatTime(Math.floor(currentSegment.start))} β†’ … +
+
+ {currentSegment.text || "Listening…"} +
+
+ )} + + ); + })()} {!isRecording && completedSegments.length === 0 && !currentSegment && (
@@ -806,67 +990,86 @@ export const CCTranscriptionPanel: React.FC = () => { {/* Summary Type Selection Modal */} {showSummaryModal && ( -
+
{ if (e.target === e.currentTarget) setShowSummaryModal(false); }} + > +
setShowSummaryModal(false)} - /> -
-
-

- Generate Summary -

+ style={{ + position: 'relative', + width: '100%', + maxWidth: '360px', + backgroundColor: 'var(--color-panel)', + border: '1px solid var(--color-divider)', + borderRadius: '10px', + boxShadow: '0 20px 60px rgba(0,0,0,0.4)', + overflow: 'hidden', + zIndex: 1, + }} + onMouseDown={(e) => e.stopPropagation()} + > +
+ Generate Summary
-
+
-
- {/* Config status indicator */}
- {llmConfig.apiKey ? ( - <>βœ“ Configured: {llmConfig.provider} ({llmConfig.model || 'default model'}) - ) : ( - <>⚠ No API key configured. Click the βš™ icon to set up. - )} + {llmConfig.model + ? <>βœ“ {llmConfig.provider} Β· {llmConfig.model} + : <>⚠ No model configured β€” open Settings first + }
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 */}
diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx new file mode 100644 index 0000000..9e1e5b0 --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx @@ -0,0 +1,586 @@ +import React, { useState, useEffect, useCallback, createContext, useContext } from 'react'; +import { + Box, IconButton, CircularProgress, Collapse, Typography, Tooltip, + ToggleButtonGroup, ToggleButton, +} from '@mui/material'; +import { + ExpandMore, ChevronRight as ChevronRightIcon, + Home as HomeIcon, + CalendarToday, DateRange, Event, + Schedule as TimetableIcon, + Class as ClassIcon, + MenuBook as CurriculumIcon, + Book as JournalIcon, + EventNote as PlannerIcon, + Business as SchoolIcon, + LinkOff as UnlinkedIcon, + HourglassEmpty as PendingIcon, + School as AcademicIcon, + GridOn as GridIcon, + Settings as SetupIcon, + Edit as EditIcon, +} from '@mui/icons-material'; +import { useNavigationStore } from '../../../../../../stores/navigationStore'; +import { useAuth } from '../../../../../../contexts/AuthContext'; +import { NeoGraphNode } from '../../../../../../types/navigation'; +import { logger } from '../../../../../../debugConfig'; +import { SchoolCalendarWizard, SchoolInfo } from './SchoolCalendarWizard'; +import { TeacherTimetableWizard, PeriodTemplate } from './TeacherTimetableWizard'; + +type NodeStatus = 'populated' | 'empty' | 'no_school' | 'not_initialized'; +type CalendarMode = 'generic' | 'academic'; + +interface TreeNode extends NeoGraphNode { + has_children?: boolean; + children?: TreeNode[]; + is_section?: boolean; + section_id?: string; + status?: NodeStatus; + neo4j_props?: Record; +} + +interface SchoolStatus { + status: string; + user_role?: string; + school_id?: string; + school_has_calendar?: boolean; + teacher_has_timetable?: boolean; + timetable_id?: string | null; + periods_template?: PeriodTemplate[] | null; + school_info?: SchoolInfo; +} + +const NODE_ICONS: Record = { + User: HomeIcon, + CalendarYear: CalendarToday, + CalendarMonth: DateRange, + CalendarWeek: DateRange, + CalendarDay: Event, + AcademicYear: AcademicIcon, + AcademicTerm: AcademicIcon, + AcademicWeek: DateRange, + TeacherTimetable: TimetableIcon, + SubjectClass: ClassIcon, + TimetableLesson: TimetableIcon, + TimetableSlot: GridIcon, + Journal: JournalIcon, + Planner: PlannerIcon, + School: SchoolIcon, + Department: SchoolIcon, + Section: HomeIcon, +}; + +const SECTION_ICONS: Record = { + calendar: CalendarToday, + timetable: TimetableIcon, + classes: ClassIcon, + curriculum: CurriculumIcon, + journal: JournalIcon, + planner: PlannerIcon, + school: SchoolIcon, +}; + +const STATUS_MESSAGES: Record = { + populated: '', + empty: 'Not set up yet', + no_school: 'Join a school to unlock', + not_initialized: 'Setting up...', +}; + +// ─── Panel context ───────────────────────────────────────────────────────────── + +interface NavPanelContextValue { + calendarMode: CalendarMode; + setCalendarMode: (m: CalendarMode) => void; + academicCalendarStatus: 'idle' | 'loading' | 'populated' | 'empty' | 'no_school' | 'error'; + academicTerms: TreeNode[]; + schoolStatus: SchoolStatus | null; + onSetupSchoolCalendar: () => void; + onSetupTimetable: () => void; + activeNodeId?: string; +} + +const NavPanelContext = createContext({ + calendarMode: 'generic', + setCalendarMode: () => {}, + academicCalendarStatus: 'idle', + academicTerms: [], + schoolStatus: null, + onSetupSchoolCalendar: () => {}, + onSetupTimetable: () => {}, +}); + +// ─── TreeItem ───────────────────────────────────────────────────────────────── + +interface TreeItemProps { + node: TreeNode; + depth: number; + onSelect: (node: TreeNode) => void; + onExpand: (node: TreeNode) => Promise; +} + +function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) { + const ctx = useContext(NavPanelContext); + const [expanded, setExpanded] = useState(node.is_section && node.status === 'populated'); + const [children, setChildren] = useState(node.children || []); + const [loading, setLoading] = useState(false); + + const isSection = !!node.is_section; + const isCalendarSection = isSection && node.section_id === 'calendar'; + const isTimetableSection = isSection && node.section_id === 'timetable'; + const isSchoolSection = isSection && node.section_id === 'school'; + const SectionIcon = node.section_id ? SECTION_ICONS[node.section_id] : null; + const Icon = SectionIcon || NODE_ICONS[node.node_type] || HomeIcon; + + const canExpand = node.has_children !== false + && node.node_type !== 'CalendarDay' + && node.node_type !== 'AcademicWeek' + && node.status !== 'empty' + && node.status !== 'no_school' + && node.status !== 'not_initialized'; + + const isActive = !isSection && node.neo4j_node_id === ctx.activeNodeId; + const isEmpty = node.status === 'empty' || node.status === 'no_school' || node.status === 'not_initialized'; + + const displayChildren = isCalendarSection && ctx.calendarMode === 'academic' + ? ctx.academicTerms + : children; + + const academicEmpty = isCalendarSection + && ctx.calendarMode === 'academic' + && (ctx.academicCalendarStatus === 'empty' || ctx.academicCalendarStatus === 'idle'); + + const handleToggle = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!expanded && displayChildren.length === 0 && canExpand && !isCalendarSection) { + setLoading(true); + try { + const loaded = await onExpand(node); + setChildren(loaded); + } finally { + setLoading(false); + } + } + setExpanded(v => !v); + }; + + const handleClick = () => { + if (!isSection) { + onSelect(node); + } else if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) { + handleToggle({ stopPropagation: () => {} } as React.MouseEvent); + } + }; + + // Derive action buttons per section + const ss = ctx.schoolStatus; + // School section: calendar setup (admin) or pending notice (non-admin) + const showCalendarSetup = isSchoolSection + && ss && ss.status !== 'no_school' + && !ss.school_has_calendar && ss.user_role === 'school_admin'; + const showCalendarPending = isSchoolSection + && ss && ss.status !== 'no_school' + && !ss.school_has_calendar && ss.user_role !== 'school_admin'; + // Timetable section: teacher timetable setup (requires school calendar first) + const showTimetableSetup = isTimetableSection && node.status === 'empty' + && ss && ss.status !== 'no_school' + && ss.school_has_calendar && !ss.teacher_has_timetable; + const showLegacySetup = isTimetableSection && node.status === 'empty' && !ss; + const showTimetableEdit = isTimetableSection && node.status === 'populated' + && ss && ss.status !== 'no_school' && !!ss.teacher_has_timetable; + + if (isSection) { + return ( + + + + {(canExpand || (isCalendarSection && !academicEmpty)) && ( + loading + ? + : ( + + {expanded + ? + : } + + ) + )} + {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && ( + + {node.status === 'no_school' + ? + : node.status === 'not_initialized' + ? + : null} + + )} + + + + + + {node.label} + + + {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && node.status && ( + + + {node.status === 'no_school' ? 'β€”' : '…'} + + + )} + + {/* Timetable section β€” role-aware action */} + {showCalendarSetup && ( + + { e.stopPropagation(); ctx.onSetupSchoolCalendar(); }} + > + + + + )} + {showCalendarPending && ( + + + + )} + {showTimetableSetup && ( + + { e.stopPropagation(); ctx.onSetupTimetable(); }} + > + + + + )} + {showLegacySetup && ( + + { e.stopPropagation(); ctx.onSetupTimetable(); }} + > + + + + )} + {showTimetableEdit && ( + + { e.stopPropagation(); ctx.onSetupTimetable(); }} + > + + + + )} + + + {/* Calendar mode toggle */} + {isCalendarSection && ( + + { if (v) ctx.setCalendarMode(v); }} + size="small" + sx={{ height: 22 }} + > + + Generic + + + Academic + + + {ctx.calendarMode === 'academic' && academicEmpty && ( + + No academic calendar β€” set up school calendar first + + )} + {ctx.calendarMode === 'academic' && ctx.academicCalendarStatus === 'loading' && ( + + )} + + )} + + {(canExpand || (isCalendarSection && ctx.calendarMode === 'academic' && displayChildren.length > 0)) && ( + + {displayChildren.map(child => ( + + ))} + + )} + + ); + } + + // Regular navigable node + return ( + + + + {canExpand && ( + loading + ? + : ( + + {expanded + ? + : } + + ) + )} + + + + {node.label} + + + + {canExpand && ( + + {children.map(child => ( + + ))} + + )} + + ); +} + +// ─── Main Panel ─────────────────────────────────────────────────────────────── + +export function CCGraphNavPanel() { + const { accessToken } = useAuth(); + const { navigateToNeoNode, context } = useNavigationStore(); + const [tree, setTree] = useState(null); + const [schoolStatus, setSchoolStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [calendarMode, setCalendarMode] = useState('generic'); + const [academicCalendarStatus, setAcademicCalendarStatus] = useState('idle'); + const [academicTerms, setAcademicTerms] = useState([]); + + const [calendarWizardOpen, setCalendarWizardOpen] = useState(false); + const [timetableWizardOpen, setTimetableWizardOpen] = useState(false); + + const apiBase = import.meta.env.VITE_API_BASE as string; + const activeNodeId = context.node?.type !== 'workspace' ? context.node?.id : undefined; + + const fetchTree = useCallback(async () => { + if (!accessToken) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`${apiBase}/graph/tree`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + setTree(data.tree); + } catch (err) { + logger.error('graph-nav-panel', 'Failed to load graph tree', err); + setError('Failed to load navigation tree'); + } finally { + setLoading(false); + } + }, [accessToken, apiBase]); + + const fetchSchoolStatus = useCallback(async () => { + if (!accessToken) return; + try { + const res = await fetch(`${apiBase}/school/status`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) return; + const data = await res.json(); + setSchoolStatus(data); + } catch { + // non-fatal β€” panel still works without school status + } + }, [accessToken, apiBase]); + + useEffect(() => { + if (accessToken && !tree) fetchTree(); + }, [accessToken, tree, fetchTree]); + + useEffect(() => { + if (accessToken && !schoolStatus) fetchSchoolStatus(); + }, [accessToken, schoolStatus, fetchSchoolStatus]); + + // Fetch academic calendar when switching to academic mode + useEffect(() => { + if (calendarMode !== 'academic' || !accessToken) return; + if (academicCalendarStatus !== 'idle') return; + setAcademicCalendarStatus('loading'); + fetch(`${apiBase}/graph/calendar/academic`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'populated') { + setAcademicTerms(data.terms); + setAcademicCalendarStatus('populated'); + } else { + setAcademicCalendarStatus(data.status || 'empty'); + } + }) + .catch(() => setAcademicCalendarStatus('error')); + }, [calendarMode, accessToken, apiBase, academicCalendarStatus]); + + const handleSetCalendarMode = useCallback((m: CalendarMode) => { + setCalendarMode(m); + if (m === 'academic') setAcademicCalendarStatus('idle'); + }, []); + + const handleExpand = useCallback(async (node: TreeNode): Promise => { + if (!accessToken) return []; + const params = new URLSearchParams({ + neo4j_node_id: node.neo4j_node_id, + neo4j_db_name: node.neo4j_db_name, + node_type: node.node_type, + section_id: node.section_id || '', + }); + try { + const res = await fetch(`${apiBase}/graph/node/children?${params}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) return []; + const data = await res.json(); + return data.children || []; + } catch { + return []; + } + }, [accessToken, apiBase]); + + const handleSelect = useCallback((node: TreeNode) => { + if (!node.is_section) navigateToNeoNode(node); + }, [navigateToNeoNode]); + + const refreshAll = useCallback(() => { + setTree(null); + setSchoolStatus(null); + setAcademicCalendarStatus('idle'); + setAcademicTerms([]); + }, []); + + const handleCalendarWizardComplete = useCallback(() => { + logger.info('graph-nav-panel', 'School calendar setup complete'); + refreshAll(); + }, [refreshAll]); + + const handleTimetableWizardComplete = useCallback((timetableId: string) => { + logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId }); + refreshAll(); + }, [refreshAll]); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!tree) return null; + + const ctxValue: NavPanelContextValue = { + calendarMode, + setCalendarMode: handleSetCalendarMode, + academicCalendarStatus, + academicTerms, + schoolStatus, + onSetupSchoolCalendar: () => setCalendarWizardOpen(true), + onSetupTimetable: () => setTimetableWizardOpen(true), + activeNodeId, + }; + + const defaultSchoolInfo: SchoolInfo = { + name: '', urn: '', website: '', address: {}, + headteacher: '', term_dates_url: '', staff_list_url: '', + }; + + return ( + + + + + + {schoolStatus?.school_info && ( + setCalendarWizardOpen(false)} + onComplete={handleCalendarWizardComplete} + apiBase={apiBase} + schoolInfo={schoolStatus.school_info || defaultSchoolInfo} + /> + )} + + setTimetableWizardOpen(false)} + onComplete={handleTimetableWizardComplete} + apiBase={apiBase} + periodsTemplate={schoolStatus?.periods_template || []} + timetableId={schoolStatus?.timetable_id || null} + /> + + ); +} diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx index 9d154a5..763ffaf 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx @@ -4,7 +4,7 @@ import Save from '@mui/icons-material/Save'; import Reset from '@mui/icons-material/RestartAlt'; import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw'; import { useNavigationStore } from '../../../../../../stores/navigationStore'; -import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService'; +import { useAuth } from '../../../../../../contexts/AuthContext'; import { PageComponent } from '../components/pageComponent'; import { logger } from '../../../../../../debugConfig'; import { useTLDraw } from '../../../../../../contexts/TLDrawContext'; @@ -68,6 +68,7 @@ export const CCNodeSnapshotPanel: React.FC = () => { const editor = useEditor(); const { addToast } = useToasts(); const { context: navigationContext, isLoading, error } = useNavigationStore(); + const { accessToken } = useAuth(); const { tldrawPreferences } = useTLDraw(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [isSaving, setIsSaving] = useState(false); @@ -131,8 +132,9 @@ export const CCNodeSnapshotPanel: React.FC = () => { type: navigationContext.node.type }); - const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node); - await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store); + const storagePath = navigationContext.node.node_storage_path; + if (!storagePath) throw new Error('No storage path on current node'); + await NavigationSnapshotService.saveNodeSnapshotToDatabase(storagePath, accessToken || '', editor.store); addToast({ title: 'Snapshot saved', diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx new file mode 100644 index 0000000..2f16a4b --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx @@ -0,0 +1,316 @@ +import React, { useState } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Button, Stepper, Step, StepLabel, Box, TextField, + Typography, IconButton, Select, MenuItem, FormControl, + InputLabel, CircularProgress, Alert, Divider, +} from '@mui/material'; +import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { useAuth } from '../../../../../../contexts/AuthContext'; + +interface TermInput { + name: string; + term_number: number; + start_date: string; + end_date: string; +} + +interface PeriodInput { + code: string; + name: string; + start_time: string; + end_time: string; + period_type: 'lesson' | 'break' | 'registration'; +} + +export interface SchoolInfo { + name: string; + urn: string; + website: string; + address: Record; + headteacher: string; + term_dates_url: string; + staff_list_url: string; +} + +const DEFAULT_TERMS: TermInput[] = [ + { name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' }, + { name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01' }, + { name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22' }, +]; + +const DEFAULT_PERIODS: PeriodInput[] = [ + { code: 'REG', name: 'Registration', start_time: '08:45', end_time: '09:00', period_type: 'registration' }, + { code: 'P1', name: 'Period 1', start_time: '09:00', end_time: '10:00', period_type: 'lesson' }, + { code: 'P2', name: 'Period 2', start_time: '10:00', end_time: '11:00', period_type: 'lesson' }, + { code: 'BREAK', name: 'Break', start_time: '11:00', end_time: '11:20', period_type: 'break' }, + { code: 'P3', name: 'Period 3', start_time: '11:20', end_time: '12:20', period_type: 'lesson' }, + { code: 'P4', name: 'Period 4', start_time: '12:20', end_time: '13:20', period_type: 'lesson' }, + { code: 'LUNCH', name: 'Lunch', start_time: '13:20', end_time: '14:05', period_type: 'break' }, + { code: 'P5', name: 'Period 5', start_time: '14:05', end_time: '15:05', period_type: 'lesson' }, +]; + +interface Props { + open: boolean; + onClose: () => void; + onComplete: () => void; + apiBase: string; + schoolInfo: SchoolInfo; +} + +export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoolInfo }: Props) { + const { accessToken } = useAuth(); + const [step, setStep] = useState(0); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || ''); + const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || ''); + const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || ''); + + const [yearStart, setYearStart] = useState('2025-09-01'); + const [yearEnd, setYearEnd] = useState('2026-07-31'); + const [terms, setTerms] = useState(DEFAULT_TERMS); + + const [periods, setPeriods] = useState(DEFAULT_PERIODS); + + const addTerm = () => setTerms(prev => [...prev, { + name: `Term ${prev.length + 1}`, + term_number: prev.length + 1, + start_date: '', + end_date: '', + }]); + + const removeTerm = (i: number) => setTerms(prev => + prev.filter((_, idx) => idx !== i).map((t, idx) => ({ ...t, term_number: idx + 1 })) + ); + + const updateTerm = (i: number, field: keyof TermInput, value: string) => + setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t)); + + const addPeriod = () => setPeriods(prev => [...prev, { + code: `P${prev.length + 1}`, + name: `Period ${prev.length + 1}`, + start_time: '', + end_time: '', + period_type: 'lesson', + }]); + + const removePeriod = (i: number) => setPeriods(prev => prev.filter((_, idx) => idx !== i)); + + const updatePeriod = (i: number, field: keyof PeriodInput, value: string) => + setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p)); + + const handleSaveSchoolInfo = async () => { + if (!accessToken) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`${apiBase}/school/info`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ headteacher, term_dates_url: termDatesUrl, staff_list_url: staffListUrl }), + }); + const data = await res.json(); + if (data.status === 'ok') { + setStep(1); + } else { + setError(data.message || 'Failed to save school info'); + } + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const handleSaveCalendar = async () => { + if (!accessToken) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`${apiBase}/timetable/setup`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ year_start: yearStart, year_end: yearEnd, terms, periods }), + }); + const data = await res.json(); + if (data.status === 'ok') { + onComplete(); + handleClose(); + } else { + setError(data.message || 'Calendar setup failed'); + } + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const handleClose = () => { + setStep(0); + setError(null); + onClose(); + }; + + const addr = schoolInfo.address || {}; + const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', '); + + const STEPS = ['School Details', 'Academic Calendar', 'Daily Periods']; + + return ( + + Set Up School Calendar + + + {STEPS.map(label => {label})} + + + + + {error && {error}} + + {step === 0 && ( + + School Information + + {schoolInfo.name || 'β€”'} + {schoolInfo.urn && ( + URN: {schoolInfo.urn} + )} + {addressStr && ( + {addressStr} + )} + {schoolInfo.website && ( + {schoolInfo.website} + )} + + + Additional Details + + setHeadteacher(e.target.value)} + size="small" + fullWidth + placeholder="e.g. Mr J Smith" + /> + setTermDatesUrl(e.target.value)} + size="small" + fullWidth + placeholder="Link to term dates page on school website" + /> + setStaffListUrl(e.target.value)} + size="small" + fullWidth + placeholder="Link to staff list page on school website" + /> + + + )} + + {step === 1 && ( + + School Year + + setYearStart(e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + setYearEnd(e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + + + + Terms + + + {terms.map((term, i) => ( + + updateTerm(i, 'name', e.target.value)} + size="small" sx={{ width: 140 }} /> + updateTerm(i, 'start_date', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + updateTerm(i, 'end_date', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + removeTerm(i)}> + + + + ))} + + )} + + {step === 2 && ( + + + Daily Period Schedule + + + {periods.map((p, i) => ( + + updatePeriod(i, 'code', e.target.value)} + size="small" sx={{ width: 80 }} /> + updatePeriod(i, 'name', e.target.value)} + size="small" sx={{ width: 140 }} /> + updatePeriod(i, 'start_time', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + updatePeriod(i, 'end_time', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + + Type + + + removePeriod(i)}> + + + + ))} + + )} + + + + + {step > 0 && ( + + )} + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + + + ); +} diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx new file mode 100644 index 0000000..0877f58 --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Button, Box, TextField, Typography, Table, TableHead, + TableBody, TableRow, TableCell, CircularProgress, Alert, +} from '@mui/material'; +import { useAuth } from '../../../../../../contexts/AuthContext'; + +export interface PeriodTemplate { + code: string; + name: string; + start_time: string; + end_time: string; + period_type: string; +} + +const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; + +function emptyGrid(): Record> { + const g: Record> = {}; + DAYS.forEach(d => { g[d] = {}; }); + return g; +} + +interface Props { + open: boolean; + onClose: () => void; + onComplete: (timetableId: string) => void; + apiBase: string; + periodsTemplate: PeriodTemplate[]; + timetableId: string | null; +} + +export function TeacherTimetableWizard({ + open, + onClose, + onComplete, + apiBase, + periodsTemplate, + timetableId: initialTimetableId, +}: Props) { + const { accessToken } = useAuth(); + const [localTimetableId, setLocalTimetableId] = useState(initialTimetableId); + const [initializing, setInitializing] = useState(false); + const [loadingSlots, setLoadingSlots] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [grid, setGrid] = useState>>(emptyGrid); + const slotsLoadedRef = useRef(false); + + const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson'); + const isEditing = !!initialTimetableId; + + // Reset when dialog opens + useEffect(() => { + if (!open) { + slotsLoadedRef.current = false; + return; + } + setLocalTimetableId(initialTimetableId); + setGrid(emptyGrid()); + setError(null); + slotsLoadedRef.current = false; + }, [open, initialTimetableId]); + + // Auto-create TeacherTimetable node if not yet done + useEffect(() => { + if (!open || localTimetableId || !accessToken || initializing) return; + setInitializing(true); + setError(null); + fetch(`${apiBase}/timetable/init`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'ok') { + setLocalTimetableId(data.timetable_id); + } else { + setError(data.message || 'Could not initialize timetable. Has an admin set up the school calendar?'); + } + }) + .catch(e => setError(e.message)) + .finally(() => setInitializing(false)); + }, [open, localTimetableId, accessToken, apiBase, initializing]); + + // Load existing slots when editing + useEffect(() => { + if (!open || !localTimetableId || !accessToken || slotsLoadedRef.current || loadingSlots) return; + slotsLoadedRef.current = true; + setLoadingSlots(true); + fetch(`${apiBase}/timetable/slots`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'ok' && Array.isArray(data.slots) && data.slots.length > 0) { + const g = emptyGrid(); + for (const slot of data.slots) { + if (g[slot.day_of_week]) { + g[slot.day_of_week][slot.period_code] = slot.subject_class || ''; + } + } + setGrid(g); + } + }) + .catch(() => {}) + .finally(() => setLoadingSlots(false)); + }, [open, localTimetableId, accessToken, apiBase, loadingSlots]); + + const setCell = (day: string, code: string, value: string) => { + setGrid(prev => ({ ...prev, [day]: { ...prev[day], [code]: value } })); + }; + + const handleSave = async () => { + if (!accessToken || !localTimetableId) return; + setSaving(true); + setError(null); + try { + const slots = []; + for (const day of DAYS) { + for (const period of lessonPeriods) { + const cls = (grid[day]?.[period.code] || '').trim(); + if (cls) { + slots.push({ + day_of_week: day, + period_code: period.code, + subject_class: cls, + start_time: period.start_time, + end_time: period.end_time, + }); + } + } + } + const res = await fetch(`${apiBase}/timetable/slots`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ timetable_id: localTimetableId, slots }), + }); + const data = await res.json(); + if (data.status === 'ok') { + onComplete(localTimetableId); + handleClose(); + } else { + setError(data.message || 'Save failed'); + } + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const handleClose = () => { + setError(null); + onClose(); + }; + + const busy = initializing || loadingSlots || saving; + + return ( + + + {isEditing ? 'Edit My Timetable' : 'Set Up My Timetable'} + + + + {error && {error}} + + {(initializing || loadingSlots) && ( + + + + {initializing ? 'Preparing your timetable…' : 'Loading existing classes…'} + + + )} + + {!initializing && !loadingSlots && localTimetableId && ( + + + Enter your class codes for each lesson slot (leave blank if free) + + + + + + Period + {DAYS.map(d => ( + + {d} + + ))} + + + + {lessonPeriods.map(period => ( + + + + + {period.code} + + + {period.start_time}–{period.end_time} + + + + {DAYS.map(day => ( + + setCell(day, period.code, e.target.value)} + inputProps={{ + style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' }, + }} + sx={{ width: 96 }} + /> + + ))} + + ))} + +
+
+
+ )} +
+ + + + + +
+ ); +}