navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table, setAuthInfo(token, userId) pattern, auto-creates default room per context on first use snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…), setAccessToken() instance method, static methods take accessToken not dbName AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken navigation types: NeoGraphNode updated for Supabase-backed tree structure transcriptionStore/Service: getSession() removed, accessToken via AuthContext LLMConfigModal: auth context wiring fixes GraphNavigator/GraphSidebar: updated nav components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
import { create } from 'zustand';
|
|
import { logger } from '../debugConfig';
|
|
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
|
import {
|
|
NavigationStore,
|
|
NavigationNode,
|
|
NeoGraphNode,
|
|
MainContext,
|
|
BaseContext,
|
|
NavigationContextState,
|
|
isProfileContext,
|
|
isInstituteContext,
|
|
addToHistory,
|
|
navigateHistory,
|
|
getCurrentHistoryNode,
|
|
ExtendedContext,
|
|
UnifiedContextSwitch,
|
|
} 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 }
|
|
};
|
|
|
|
function getDefaultBaseForMain(main: MainContext): BaseContext {
|
|
return main === 'profile' ? 'profile' : 'school';
|
|
}
|
|
|
|
function validateContextTransition(
|
|
current: NavigationContextState,
|
|
updates: Partial<NavigationContextState>
|
|
): NavigationContextState {
|
|
const newState = { ...current, ...updates };
|
|
if (updates.main) {
|
|
newState.base = getDefaultBaseForMain(updates.main);
|
|
}
|
|
if (updates.base) {
|
|
const isValid = newState.main === 'profile'
|
|
? isProfileContext(updates.base)
|
|
: isInstituteContext(updates.base);
|
|
if (!isValid) {
|
|
newState.base = getDefaultBaseForMain(newState.main);
|
|
}
|
|
}
|
|
return newState;
|
|
}
|
|
|
|
export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
|
|
const pgFetch = async <T = unknown>(
|
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
|
table: string,
|
|
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
|
|
): Promise<T | null> => {
|
|
const token = get()._accessToken;
|
|
if (!token) throw new Error('pgFetch: no access token');
|
|
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`;
|
|
const headers: Record<string, string> = {
|
|
'Authorization': `Bearer ${token}`,
|
|
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
if (options.prefer) headers['Prefer'] = options.prefer;
|
|
if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json';
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers,
|
|
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`PostgREST ${res.status}: ${err}`);
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json() as Promise<T>;
|
|
};
|
|
|
|
const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
|
|
const userId = get()._userId;
|
|
if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
|
|
|
|
const rooms = await pgFetch<WhiteboardRoom[]>('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<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
|
body: {
|
|
user_id: userId,
|
|
name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`,
|
|
context_type: contextType,
|
|
is_default: true,
|
|
storage_path: storagePath,
|
|
},
|
|
prefer: 'return=representation',
|
|
single: true,
|
|
});
|
|
|
|
if (!room) throw new Error('Failed to create default whiteboard room');
|
|
logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id });
|
|
|
|
return {
|
|
id: room.id,
|
|
node_storage_path: room.storage_path || storagePath,
|
|
label: room.name,
|
|
type: 'workspace',
|
|
};
|
|
};
|
|
|
|
return {
|
|
_accessToken: null,
|
|
_userId: null,
|
|
setAuthInfo: (token: string | null, userId: string | null) => {
|
|
set({ _accessToken: token, _userId: userId });
|
|
},
|
|
|
|
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 };
|
|
|
|
if (contextSwitch.main) {
|
|
newState = validateContextTransition(newState, { main: contextSwitch.main });
|
|
if (!contextSwitch.skipBaseContextLoad) {
|
|
newState.base = getDefaultBaseForMain(contextSwitch.main);
|
|
}
|
|
}
|
|
if (contextSwitch.base) {
|
|
newState = validateContextTransition(newState, { base: contextSwitch.base });
|
|
}
|
|
|
|
const targetContext = contextSwitch.base ||
|
|
contextSwitch.extended ||
|
|
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
|
|
|
|
const defaultNode = await getOrCreateDefaultRoom(targetContext);
|
|
const newHistory = addToHistory(currentState.history, defaultNode);
|
|
|
|
set({
|
|
context: { ...newState, node: defaultNode, history: newHistory },
|
|
isLoading: false,
|
|
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<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
|
query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`,
|
|
});
|
|
if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`);
|
|
|
|
const room = rooms[0];
|
|
const node: NavigationNode = {
|
|
id: 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<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
|
query: `id=eq.${currentNode.id}`,
|
|
});
|
|
if (rooms && rooms.length > 0) {
|
|
const room = rooms[0];
|
|
set({
|
|
context: {
|
|
...get().context,
|
|
node: {
|
|
id: room.id,
|
|
node_storage_path: room.storage_path || currentNode.node_storage_path,
|
|
label: room.name,
|
|
type: 'workspace',
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
set({ isLoading: false });
|
|
} catch (error) {
|
|
logger.error('navigation', '❌ refreshNavigationState failed', error);
|
|
set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false });
|
|
}
|
|
},
|
|
|
|
navigateToNeoNode: async (neoNode: NeoGraphNode) => {
|
|
const userId = get()._userId;
|
|
if (!userId || !get()._accessToken) {
|
|
logger.warn('navigation', '⚠️ navigateToNeoNode called without auth');
|
|
return;
|
|
}
|
|
try {
|
|
set({ isLoading: true, error: null });
|
|
const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
|
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`,
|
|
});
|
|
let room: WhiteboardRoom;
|
|
if (existing && existing.length > 0) {
|
|
room = existing[0];
|
|
} else {
|
|
const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`;
|
|
const created = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
|
body: {
|
|
user_id: userId,
|
|
name: neoNode.label,
|
|
context_type: neoNode.node_type.toLowerCase(),
|
|
is_default: false,
|
|
storage_path: storagePath,
|
|
neo4j_node_id: neoNode.neo4j_node_id,
|
|
neo4j_db_name: neoNode.neo4j_db_name,
|
|
node_type: neoNode.node_type,
|
|
},
|
|
prefer: 'return=representation',
|
|
single: true,
|
|
});
|
|
if (!created) throw new Error('Failed to create whiteboard room for node');
|
|
room = created;
|
|
}
|
|
const node: NavigationNode = {
|
|
id: 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 });
|
|
}
|
|
},
|
|
};
|
|
});
|