navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table, setAuthInfo(token, userId) pattern, auto-creates default room per context on first use snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…), setAccessToken() instance method, static methods take accessToken not dbName AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken navigation types: NeoGraphNode updated for Supabase-backed tree structure transcriptionStore/Service: getSession() removed, accessToken via AuthContext LLMConfigModal: auth context wiring fixes GraphNavigator/GraphSidebar: updated nav components Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
670 lines
20 KiB
TypeScript
670 lines
20 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||
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 { CalendarStructure, WorkerStructure } from '../types/navigation';
|
||
import { useNavigationStore } from '../stores/navigationStore';
|
||
|
||
// Core Node Types
|
||
export interface CalendarNode {
|
||
id: string;
|
||
label: string;
|
||
title: string;
|
||
node_storage_path: string;
|
||
type?: CCCalendarNodeProps['__primarylabel__'];
|
||
nodeData?: CCCalendarNodeProps;
|
||
}
|
||
|
||
export interface WorkerNode {
|
||
id: string;
|
||
label: string;
|
||
title: string;
|
||
node_storage_path: string;
|
||
type?: CCUserTeacherTimetableNodeProps['__primarylabel__'];
|
||
nodeData?: CCUserTeacherTimetableNodeProps;
|
||
}
|
||
|
||
// Calendar Structure Types
|
||
export interface CalendarDay {
|
||
id: string;
|
||
date: string;
|
||
title: string;
|
||
}
|
||
|
||
export interface CalendarWeek {
|
||
id: string;
|
||
title: string;
|
||
days: { id: string }[];
|
||
startDate: string;
|
||
endDate: string;
|
||
}
|
||
|
||
export interface CalendarMonth {
|
||
id: string;
|
||
title: string;
|
||
days: { id: string }[];
|
||
weeks: { id: string }[];
|
||
year: string;
|
||
month: string;
|
||
}
|
||
|
||
export interface CalendarYear {
|
||
id: string;
|
||
title: string;
|
||
months: { id: string }[];
|
||
year: string;
|
||
}
|
||
|
||
// Worker Structure Types
|
||
export interface TimetableEntry {
|
||
id: string;
|
||
title: string;
|
||
type: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
}
|
||
|
||
export interface ClassEntry {
|
||
id: string;
|
||
title: string;
|
||
type: string;
|
||
}
|
||
|
||
export interface LessonEntry {
|
||
id: string;
|
||
title: string;
|
||
type: string;
|
||
}
|
||
|
||
interface NeoUserContextType {
|
||
userNode: CCUserNodeProps | null;
|
||
calendarNode: CalendarNode | null;
|
||
workerNode: WorkerNode | null;
|
||
userDbName: string | null;
|
||
workerDbName: string | null;
|
||
isLoading: boolean;
|
||
isInitialized: boolean;
|
||
error: string | null;
|
||
|
||
// Calendar Navigation
|
||
navigateToDay: (id: string) => Promise<void>;
|
||
navigateToWeek: (id: string) => Promise<void>;
|
||
navigateToMonth: (id: string) => Promise<void>;
|
||
navigateToYear: (id: string) => Promise<void>;
|
||
currentCalendarNode: CalendarNode | null;
|
||
calendarStructure: CalendarStructure | null;
|
||
|
||
// Worker Navigation
|
||
navigateToTimetable: (id: string) => Promise<void>;
|
||
navigateToJournal: (id: string) => Promise<void>;
|
||
navigateToPlanner: (id: string) => Promise<void>;
|
||
navigateToClass: (id: string) => Promise<void>;
|
||
navigateToLesson: (id: string) => Promise<void>;
|
||
currentWorkerNode: WorkerNode | null;
|
||
workerStructure: WorkerStructure | null;
|
||
}
|
||
|
||
const NeoUserContext = createContext<NeoUserContextType>({
|
||
userNode: null,
|
||
calendarNode: null,
|
||
workerNode: null,
|
||
userDbName: null,
|
||
workerDbName: null,
|
||
isLoading: false,
|
||
isInitialized: false,
|
||
error: null,
|
||
navigateToDay: async () => {},
|
||
navigateToWeek: async () => {},
|
||
navigateToMonth: async () => {},
|
||
navigateToYear: async () => {},
|
||
navigateToTimetable: async () => {},
|
||
navigateToJournal: async () => {},
|
||
navigateToPlanner: async () => {},
|
||
navigateToClass: async () => {},
|
||
navigateToLesson: async () => {},
|
||
currentCalendarNode: null,
|
||
currentWorkerNode: null,
|
||
calendarStructure: null,
|
||
workerStructure: null
|
||
});
|
||
|
||
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||
const { user, accessToken } = useAuth();
|
||
const { profile, isInitialized: isUserInitialized } = useUser();
|
||
const navigationStore = useNavigationStore();
|
||
|
||
const [userNode, setUserNode] = useState<CCUserNodeProps | null>(null);
|
||
const [calendarNode] = useState<CalendarNode | null>(null);
|
||
const [workerNode] = useState<WorkerNode | null>(null);
|
||
const [currentCalendarNode, setCurrentCalendarNode] = useState<CalendarNode | null>(null);
|
||
const [currentWorkerNode, setCurrentWorkerNode] = useState<WorkerNode | null>(null);
|
||
const [calendarStructure] = useState<CalendarStructure | null>(null);
|
||
const [workerStructure] = useState<WorkerStructure | null>(null);
|
||
const [userDbName, setUserDbName] = useState<string | null>(null);
|
||
const [workerDbName, setWorkerDbName] = useState<string | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isInitialized, setIsInitialized] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Use ref for initialization tracking to prevent re-renders
|
||
const initializationRef = React.useRef({
|
||
hasStarted: false,
|
||
isComplete: false
|
||
});
|
||
|
||
// Add base properties for node data
|
||
const getBaseNodeProps = () => ({
|
||
title: '',
|
||
w: 200,
|
||
h: 200,
|
||
headerColor: '#000000',
|
||
backgroundColor: '#ffffff',
|
||
isLocked: false,
|
||
__primarylabel__: 'UserTeacherTimetable',
|
||
uuid_string: '',
|
||
node_storage_path: '',
|
||
created: new Date().toISOString(),
|
||
merged: new Date().toISOString(),
|
||
state: {
|
||
parentId: null,
|
||
isPageChild: false,
|
||
hasChildren: false,
|
||
bindings: [],
|
||
},
|
||
defaultComponent: true,
|
||
});
|
||
|
||
// Initialize context when dependencies are ready
|
||
useEffect(() => {
|
||
logger.debug('neo-user-context', '🔄 useEffect triggered', {
|
||
isUserInitialized,
|
||
hasProfile: !!profile,
|
||
hasUser: !!user,
|
||
isInitialized,
|
||
hasStarted: initializationRef.current.hasStarted
|
||
});
|
||
|
||
if (!isUserInitialized) {
|
||
logger.debug('neo-user-context', '⏳ Waiting for user context initialization');
|
||
return;
|
||
}
|
||
|
||
if (!profile) {
|
||
if (!initializationRef.current.isComplete) {
|
||
setIsLoading(false);
|
||
setIsInitialized(true);
|
||
initializationRef.current.isComplete = true;
|
||
logger.debug('neo-user-context', 'ℹ️ No profile available; marking context initialized');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isInitialized || initializationRef.current.hasStarted) {
|
||
logger.debug('neo-user-context', 'ℹ️ Initialization already in progress or complete', {
|
||
isInitialized,
|
||
hasStarted: initializationRef.current.hasStarted
|
||
});
|
||
return;
|
||
}
|
||
|
||
const initializeContext = async () => {
|
||
try {
|
||
initializationRef.current.hasStarted = true;
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
// Inject auth into navigation store so Supabase queries work
|
||
if (user?.id && accessToken) {
|
||
navigationStore.setAuthInfo(accessToken, user.id);
|
||
}
|
||
|
||
// Initialize user node in profile context
|
||
logger.debug('neo-user-context', '🔄 Starting context initialization');
|
||
|
||
// Initialize user node — race against 8s timeout so spinner never hangs
|
||
const switchTimeout = new Promise<void>((_, reject) =>
|
||
setTimeout(() => reject(new Error('switchContext timed out after 8000ms')), 8000)
|
||
)
|
||
try {
|
||
await Promise.race([
|
||
navigationStore.switchContext({
|
||
main: 'profile',
|
||
base: 'profile',
|
||
extended: 'overview'
|
||
}, null, null),
|
||
switchTimeout
|
||
]);
|
||
|
||
const userNavigationNode = navigationStore.context.node;
|
||
if (userNavigationNode?.id && userNavigationNode?.data) {
|
||
const userNodeData: CCUserNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'User',
|
||
uuid_string: userNavigationNode.id,
|
||
node_storage_path: userNavigationNode.node_storage_path || '',
|
||
title: String(userNavigationNode.data?.user_name || 'User'),
|
||
user_name: String(userNavigationNode.data?.user_name || 'User'),
|
||
user_email: user?.email || '',
|
||
user_type: 'User',
|
||
user_id: userNavigationNode.id,
|
||
worker_node_data: JSON.stringify(userNavigationNode.data || {})
|
||
};
|
||
setUserNode(userNodeData);
|
||
logger.debug('neo-user-context', '✅ User node loaded from navigation store');
|
||
} else if (userNavigationNode?.id) {
|
||
logger.debug('neo-user-context', 'ℹ️ User node exists but data not yet loaded - will retry later', {
|
||
nodeId: userNavigationNode.id,
|
||
hasData: !!userNavigationNode.data
|
||
});
|
||
} else {
|
||
logger.debug('neo-user-context', 'ℹ️ No user node in navigation store yet - will retry later');
|
||
}
|
||
} catch (navError) {
|
||
logger.warn('neo-user-context', '⚠️ Navigation store initialization failed - continuing without user node', {
|
||
error: navError instanceof Error ? navError.message : String(navError)
|
||
});
|
||
// Continue without user node - this is not critical for basic functionality
|
||
}
|
||
|
||
// Set final state — userDbName signals auth availability for UI guards
|
||
setUserDbName(user?.id || null);
|
||
setWorkerDbName(null);
|
||
setIsInitialized(true);
|
||
setIsLoading(false);
|
||
initializationRef.current.isComplete = true;
|
||
|
||
logger.debug('neo-user-context', '✅ Context initialization complete');
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize user context';
|
||
logger.error('neo-user-context', '❌ Failed to initialize context', { error: errorMessage });
|
||
setError(errorMessage);
|
||
setIsLoading(false);
|
||
setIsInitialized(true);
|
||
initializationRef.current.isComplete = true;
|
||
}
|
||
};
|
||
|
||
initializeContext();
|
||
}, [user, profile, isUserInitialized, navigationStore, isInitialized]);
|
||
|
||
// Calendar Navigation Functions
|
||
const navigateToDay = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'calendar',
|
||
extended: 'day'
|
||
}, null, null);
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCCalendarNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'CalendarDay',
|
||
uuid_string: id || node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
name: node.label,
|
||
calendar_type: 'day',
|
||
calendar_name: node.label,
|
||
start_date: new Date().toISOString(),
|
||
end_date: new Date().toISOString()
|
||
};
|
||
|
||
setCurrentCalendarNode({
|
||
id: id || node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'CalendarDay',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to day');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToWeek = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'calendar',
|
||
extended: 'week'
|
||
}, null, null);
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCCalendarNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'CalendarWeek',
|
||
uuid_string: id || node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
name: node.label,
|
||
calendar_type: 'week',
|
||
calendar_name: node.label,
|
||
start_date: new Date().toISOString(),
|
||
end_date: new Date().toISOString()
|
||
};
|
||
|
||
setCurrentCalendarNode({
|
||
id: id || node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'CalendarWeek',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to week');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToMonth = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'calendar',
|
||
extended: 'month'
|
||
}, null, null);
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCCalendarNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'CalendarMonth',
|
||
uuid_string: id || node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
name: node.label,
|
||
calendar_type: 'month',
|
||
calendar_name: node.label,
|
||
start_date: new Date().toISOString(),
|
||
end_date: new Date().toISOString()
|
||
};
|
||
|
||
setCurrentCalendarNode({
|
||
id: id || node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'CalendarMonth',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to month');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToYear = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'calendar',
|
||
extended: 'year'
|
||
}, null, null);
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCCalendarNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'CalendarYear',
|
||
uuid_string: id || node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
name: node.label,
|
||
calendar_type: 'year',
|
||
calendar_name: node.label,
|
||
start_date: new Date().toISOString(),
|
||
end_date: new Date().toISOString()
|
||
};
|
||
|
||
setCurrentCalendarNode({
|
||
id: id || node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'CalendarYear',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to year');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// Worker Navigation Functions
|
||
const navigateToTimetable = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'teaching',
|
||
extended: 'timetable'
|
||
}, null, null);
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'UserTeacherTimetable',
|
||
uuid_string: id || node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
school_db_name: workerDbName || '',
|
||
school_timetable_id: id || node.id
|
||
};
|
||
|
||
setCurrentWorkerNode({
|
||
id: id || node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'UserTeacherTimetable',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to timetable');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToJournal = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'teaching',
|
||
extended: 'journal'
|
||
}, null, null);
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'UserTeacherTimetable',
|
||
uuid_string: id || node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
school_db_name: workerDbName || '',
|
||
school_timetable_id: id || node.id
|
||
};
|
||
|
||
setCurrentWorkerNode({
|
||
id: id || node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'UserTeacherTimetable',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to journal');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToPlanner = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'teaching',
|
||
extended: 'planner'
|
||
}, null, null);
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'UserTeacherTimetable',
|
||
uuid_string: id || node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
school_db_name: workerDbName || '',
|
||
school_timetable_id: id || node.id
|
||
};
|
||
|
||
setCurrentWorkerNode({
|
||
id: id || node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'UserTeacherTimetable',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to planner');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToClass = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'teaching',
|
||
extended: 'classes'
|
||
}, null, null);
|
||
await navigationStore.navigate(id, '');
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'UserTeacherTimetable',
|
||
uuid_string: node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
school_db_name: workerDbName || '',
|
||
school_timetable_id: node.id
|
||
};
|
||
|
||
setCurrentWorkerNode({
|
||
id: node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'UserTeacherTimetable',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to class');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToLesson = async (id: string) => {
|
||
if (!user?.id) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await navigationStore.switchContext({
|
||
base: 'teaching',
|
||
extended: 'lessons'
|
||
}, null, null);
|
||
await navigationStore.navigate(id, '');
|
||
|
||
const node = navigationStore.context.node;
|
||
if (node?.data) {
|
||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||
...getBaseNodeProps(),
|
||
__primarylabel__: 'UserTeacherTimetable',
|
||
uuid_string: node.id,
|
||
node_storage_path: node.node_storage_path || '',
|
||
title: node.label,
|
||
school_db_name: workerDbName || '',
|
||
school_timetable_id: node.id
|
||
};
|
||
|
||
setCurrentWorkerNode({
|
||
id: node.id,
|
||
label: node.label,
|
||
title: node.label,
|
||
node_storage_path: node.node_storage_path || '',
|
||
type: 'UserTeacherTimetable',
|
||
nodeData
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : 'Failed to navigate to lesson');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<NeoUserContext.Provider value={{
|
||
userNode,
|
||
calendarNode,
|
||
workerNode,
|
||
userDbName,
|
||
workerDbName,
|
||
isLoading,
|
||
isInitialized,
|
||
error,
|
||
navigateToDay,
|
||
navigateToWeek,
|
||
navigateToMonth,
|
||
navigateToYear,
|
||
navigateToTimetable,
|
||
navigateToJournal,
|
||
navigateToPlanner,
|
||
navigateToClass,
|
||
navigateToLesson,
|
||
currentCalendarNode,
|
||
currentWorkerNode,
|
||
calendarStructure,
|
||
workerStructure
|
||
}}>
|
||
{children}
|
||
</NeoUserContext.Provider>
|
||
);
|
||
};
|
||
|
||
export const useNeoUser = () => useContext(NeoUserContext);
|