From 7b546c933ed8d70fcc4de8a136a6e616bc37b4ac Mon Sep 17 00:00:00 2001 From: kcar Date: Mon, 25 May 2026 13:06:39 +0000 Subject: [PATCH 1/7] feat(phase-a): remove Neo4j from startup chain, clean auth flow - Remove NeoUserProvider + NeoInstituteProvider from App.tsx startup chain - Strip user_db_name/school_db_name from CCUser; add school_id (Phase B wires it to Supabase) - Remove DatabaseNameService from AuthContext and UserContext - Remove provisionUser() call from login path; API endpoint preserved for Phase B decision - Simplify UserContext.resolveProfile: fast-path JWT metadata then background Supabase fetch - Replace user.user_db_name reads in singlePlayerPage + snapshotService with null-safe guards - Add useDeviceContext hook (desktop/tablet/phone/iwb, persists to localStorage) App now loads to dashboard without any Neo4j dependency at startup. Canvas opens to blank TLDraw state; Phase B rebuilds navigation on Supabase. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 2 - src/contexts/AuthContext.tsx | 10 +-- src/contexts/UserContext.tsx | 103 ++--------------------- src/hooks/useDeviceContext.ts | 37 ++++++++ src/pages/tldraw/singlePlayerPage.tsx | 4 +- src/services/auth/authService.ts | 23 +---- src/services/auth/registrationService.ts | 14 --- src/services/tldraw/snapshotService.ts | 16 +++- 8 files changed, 63 insertions(+), 146 deletions(-) create mode 100644 src/hooks/useDeviceContext.ts diff --git a/src/App.tsx b/src/App.tsx index 36884a6..eadb406 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,6 @@ 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'; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index b6e89ac..3fce4bd 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -4,7 +4,6 @@ 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 { @@ -52,20 +51,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 }; 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/singlePlayerPage.tsx b/src/pages/tldraw/singlePlayerPage.tsx index 7ccf24c..5ccbb67 100644 --- a/src/pages/tldraw/singlePlayerPage.tsx +++ b/src/pages/tldraw/singlePlayerPage.tsx @@ -122,7 +122,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,7 +131,7 @@ export default function SinglePlayerPage() { await NavigationSnapshotService.loadNodeSnapshotFromDatabase( nodeStoragePath, - user.user_db_name, + null, newStore, setLoadingState, undefined, // sharedStore 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..b6b3b64 100644 --- a/src/services/tldraw/snapshotService.ts +++ b/src/services/tldraw/snapshotService.ts @@ -281,8 +281,12 @@ export class NavigationSnapshotService { throw new Error('No user found'); } - const dbName = user.user_db_name; - + const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? ''; + if (!dbName) { + logger.debug('snapshot-service', '⚠️ No db name - snapshot save skipped (Phase B will migrate to Supabase Storage)'); + return; + } + logger.debug('snapshot-service', 'πŸ’Ύ Saving snapshot', { nodePath, dbName, @@ -315,8 +319,12 @@ export class NavigationSnapshotService { throw new Error('No user found'); } - const dbName = user.user_db_name; - + const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? ''; + if (!dbName) { + logger.debug('snapshot-service', '⚠️ No db name - snapshot load skipped (Phase B will migrate to Supabase Storage)'); + return; + } + logger.debug('snapshot-service', 'πŸ“₯ Loading snapshot', { nodePath: node.node_storage_path, dbName, From fb1795fd2b3d044226154dc0aecfda56dc89b4c3 Mon Sep 17 00:00:00 2001 From: kcar Date: Mon, 25 May 2026 13:22:24 +0000 Subject: [PATCH 2/7] fix(phase-a): actually remove Neo provider JSX from App.tsx Python heredoc string replacement missed the JSX wrapper lines due to indentation mismatch. Rewrote App.tsx directly. Previous commit removed the imports but left NeoUserProvider/NeoInstituteProvider in the JSX, causing ReferenceError at runtime (Vite builds without tsc so it compiled clean despite the undefined references). Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index eadb406..5921c17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,26 +7,20 @@ import { UserProvider } from './contexts/UserContext'; 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; From 5284d30f841e3bbe6bd2875b5ffb979fcdd478c2 Mon Sep 17 00:00:00 2001 From: kcar Date: Mon, 25 May 2026 13:41:25 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix(panels):=20resolve=20sidebar=20refresh?= =?UTF-8?q?=20bugs=20=E2=80=94=20getUser=E2=86=92getSession,=20files=20dou?= =?UTF-8?q?ble-call,=20hardcoded=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transcriptionStore: replace all supabase.auth.getUser() with getSession() so session restoration on page refresh does not race against GoTrue network validation - CCFilesPanel: remove selectedCabinet from loadCabinets useCallback deps; use initialSelectionDone ref to prevent double-call on first mount - CCTranscriptionPanel: replace hardcoded LAN IP with VITE_API_URL env var Co-Authored-By: Claude Sonnet 4.6 --- src/stores/transcriptionStore.ts | 204 ++++++-- .../components/shared/CCFilesPanel.tsx | 8 +- .../shared/CCTranscriptionPanel.tsx | 453 +++++++++++++----- 3 files changed, 502 insertions(+), 163 deletions(-) diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts index bf310d8..25455bb 100644 --- a/src/stores/transcriptionStore.ts +++ b/src/stores/transcriptionStore.ts @@ -23,6 +23,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 +41,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 +80,8 @@ function loadLLMConfig(): LLMConfig { provider: 'openai', model: '', apiKey: '', + baseUrl: '', + whisperModel: 'large-v3', }; } @@ -90,8 +100,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[]; @@ -122,6 +133,7 @@ interface TranscriptionState { // 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; @@ -156,6 +168,7 @@ export const useTranscriptionStore = create((set, get) => ({ isConnecting: false, activeSession: null, completedSegments: [], + serverWindow: [], currentSegment: null, pendingCanvasEvents: [], timetableContext: null, @@ -187,14 +200,14 @@ export const useTranscriptionStore = create((set, get) => ({ // Create session in Supabase try { - const user = await supabase.auth.getUser(); - if (!user.data.user) { + const { data: sessionData_auth } = await supabase.auth.getSession(); + if (!sessionData_auth.session?.user) { console.error('No authenticated user'); return; } const sessionData = { - user_id: user.data.user.id, + user_id: sessionData_auth.session.user.id, title: timetableTag?.event_label || 'Untitled Session', canvas_type: 'teaching-canvas', timetable_period_id: timetableTag?.period_id || null, @@ -221,7 +234,32 @@ export const useTranscriptionStore = create((set, get) => ({ }, stopSession: async () => { - const { activeSession, completedSegments } = get(); + 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) { + supabase.from('transcription_segments').insert({ + session_id: activeSession.id, + sequence_index: idx, + text: currentSegment.text, + start_seconds: currentSegment.start, + end_seconds: currentSegment.end, + is_final: true, + }).then(({ error }) => { if (error) console.error('Failed to save live segment on stop:', error); }); + } + } + } + + const finalWordCount = newCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 + ); if (activeSession) { try { @@ -229,8 +267,8 @@ export const useTranscriptionStore = create((set, get) => ({ .from('transcription_sessions') .update({ ended_at: new Date().toISOString(), - word_count: get().wordCount, - segment_count: completedSegments.length, + word_count: finalWordCount, + segment_count: newCompleted.length, }) .eq('id', activeSession.id); } catch (error) { @@ -242,35 +280,121 @@ export const useTranscriptionStore = create((set, get) => ({ isRecording: false, isConnecting: false, activeSession: null, + completedSegments: newCompleted, + serverWindow: [], + currentSegment: null, + wordCount: finalWordCount, + }); + }, + + updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => { + const { completedSegments, activeSession } = get(); + + if (segments.length === 0) return; + + // The server marks every finalized segment with completed=true and the live + // one with completed=false. Rather than relying on window-scroll detection + // (which can miss segments when the server creates several at once), we + // directly merge every completed segment from this message into the store. + // This guarantees no gaps: any segment the server says is complete is captured + // immediately, regardless of how many were created since the last message. + const serverCompleted = isLastLive ? segments.slice(0, -1) : segments; + + let newCompleted = [...completedSegments]; + const toSave: Array<{ seg: ServerSegment; idx: number }> = []; + + for (const seg of serverCompleted) { + if (!seg.text.trim()) continue; + const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5); + if (existingIdx >= 0) { + // Server refined an existing segment β€” update text and end time in place. + newCompleted[existingIdx] = { + ...newCompleted[existingIdx], + text: seg.text, + end: seg.end, + }; + } else { + const newIdx = newCompleted.length; + newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end }); + toSave.push({ seg, idx: newIdx }); + } + } + + // Keep sorted by start time so display order is always correct. + newCompleted.sort((a, b) => a.start - b.start); + + // Persist and keyword-check only truly new segments. + if (toSave.length > 0) { + const elapsed = get().elapsedSeconds; + for (const { seg, idx } of toSave) { + if (activeSession) { + supabase.from('transcription_segments').insert({ + session_id: activeSession.id, + sequence_index: idx, + text: seg.text, + start_seconds: seg.start, + end_seconds: seg.end, + is_final: true, + }).then(({ error }) => { if (error) console.error('Failed to save segment:', error); }); + } + get().checkSegmentForKeywords(seg.text, elapsed); + } + } + + const lastSeg = segments[segments.length - 1]; + const newCurrentSegment: TranscriptionSegment | null = isLastLive + ? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end } + : null; + + const newWordCount = newCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 + ); + + set({ + serverWindow: segments, + completedSegments: newCompleted, + currentSegment: newCurrentSegment, + wordCount: newWordCount, }); }, saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { - const { completedSegments, currentSegment, activeSession, wordCount } = get(); + const { completedSegments, currentSegment, activeSession } = get(); 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 }]; + // 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; + } + const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); - set({ - completedSegments: newCompleted, - currentSegment: null, - wordCount: newWordCount, - }); + set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); - // Save to Supabase if session is active - if (activeSession) { + if (isNew && activeSession) { try { - const sequenceIndex = newCompleted.length - 1; await supabase.from('transcription_segments').insert({ session_id: activeSession.id, - sequence_index: sequenceIndex, - text: text, + sequence_index: newCompleted.length - 1, + text, start_seconds: metadata.start, end_seconds: metadata.end, is_final: true, @@ -280,7 +404,26 @@ export const useTranscriptionStore = create((set, get) => ({ } } } else { - // In-progress segment + // 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) { + supabase.from('transcription_segments').insert({ + session_id: activeSession.id, + sequence_index: autoCompleted.length - 1, + text: currentSegment.text, + start_seconds: currentSegment.start, + end_seconds: currentSegment.end, + is_final: true, + }).then(({ error }) => { if (error) console.error('Failed to save auto-committed segment:', error); }); + } + } set({ currentSegment: { text, isFinal: false, ...metadata } }); } }, @@ -290,6 +433,7 @@ export const useTranscriptionStore = create((set, get) => ({ isRecording: false, isConnecting: false, completedSegments: [], + serverWindow: [], currentSegment: null, wordCount: 0, elapsedSeconds: 0, @@ -321,7 +465,7 @@ export const useTranscriptionStore = create((set, get) => ({ 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 || '', + user_id: (await supabase.auth.getSession()).data.session?.user?.id || '', timestamp: new Date().toISOString(), session_elapsed_seconds: event.sessionElapsedSeconds || null, event_type: event.eventType, @@ -340,13 +484,13 @@ export const useTranscriptionStore = create((set, get) => ({ loadSessions: async (): Promise => { try { - const user = await supabase.auth.getUser(); - if (!user.data.user) return []; + const { data: sessionData_auth } = await supabase.auth.getSession(); + if (!sessionData_auth.session?.user) return []; const { data, error } = await supabase .from('transcription_sessions') .select('*') - .eq('user_id', user.data.user.id) + .eq('user_id', sessionData_auth.session.user.id) .order('started_at', { ascending: false }) .limit(50); @@ -457,9 +601,7 @@ export const useTranscriptionStore = create((set, get) => ({ 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; + if (!session?.access_token || !session.user) return; const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { method: 'POST', @@ -468,7 +610,7 @@ export const useTranscriptionStore = create((set, get) => ({ 'Authorization': `Bearer ${session.access_token}`, }, body: JSON.stringify({ - user_id: user.data.user.id, + user_id: session.user.id, keyword: keyword.trim(), match_type: 'contains', action: 'alert', diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx index 881cc4b..eb5f802 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx @@ -115,6 +115,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([]); @@ -158,13 +159,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; diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx index 6cd8ef2..b2922b7 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; 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 +17,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 +51,7 @@ export const CCTranscriptionPanel: React.FC = () => { const { isRecording, completedSegments, + serverWindow, currentSegment, wordCount, elapsedSeconds, @@ -38,7 +59,7 @@ export const CCTranscriptionPanel: React.FC = () => { timetableContext, startSession, stopSession, - saveSegment, + updateServerWindow, resetSession, tickElapsed, addCanvasEvent, @@ -62,6 +83,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); @@ -87,7 +109,8 @@ export const CCTranscriptionPanel: React.FC = () => { 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 +150,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 +201,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 +219,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 +486,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 +980,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 + }
From 6bd85671d27b4b4b78714b70b49cb0a29351c03d Mon Sep 17 00:00:00 2001 From: kcar Date: Mon, 25 May 2026 15:06:51 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix(panels):=20bypass=20GoTrueClient=20lock?= =?UTF-8?q?=20=E2=80=94=20expose=20accessToken=20via=20AuthContext,=20gate?= =?UTF-8?q?=20panels=20on=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: apiFetch called supabase.auth.getSession() on every API request, acquiring the GoTrueClient internal lock. On refresh, concurrent mount of panels caused lock contention β€” the loadCabinets/loadSessions fetch awaits hung, loading spinner never cleared. - AuthContext: add accessToken state, set/clear it alongside user on all auth events - CCFilesPanel: apiFetch reads accessToken from AuthContext (no lock), loadCabinets effect gated on authUser.id (fires only after SIGNED_IN, session guaranteed valid) - CCTranscriptionPanel: loadSessions effect gated on authUser.id for same reason Co-Authored-By: Claude Sonnet 4.6 --- src/contexts/AuthContext.tsx | 9 +++++++++ .../components/shared/CCFilesPanel.tsx | 16 ++++++++++------ .../components/shared/CCTranscriptionPanel.tsx | 12 ++++++++---- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 3fce4bd..9886446 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -9,6 +9,7 @@ import { storageService, StorageKeys } from '../services/auth/localStorageServic export interface AuthContextType { user: CCUser | null; user_role: string | null; + accessToken: string | null; loading: boolean; error: Error | null; signIn: (email: string, password: string) => Promise; @@ -19,6 +20,7 @@ export interface AuthContextType { export const AuthContext = createContext({ user: null, user_role: null, + accessToken: null, loading: true, error: null, signIn: async () => {}, @@ -30,6 +32,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const [user, setUser] = useState(null); const [user_role, setUserRole] = useState(null); + const [accessToken, setAccessToken] = useState(null); const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires const [error, setError] = useState(null); @@ -84,15 +87,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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); } // Always clear loading after the first auth event resolves setLoading(false); @@ -102,6 +108,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { persistSession(null); setUser(null); setUserRole(null); + setAccessToken(null); setLoading(false); break; } @@ -132,6 +139,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 }); @@ -165,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { value={{ user, user_role, + accessToken, loading, error, signIn, diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx index eb5f802..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(''); @@ -144,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); @@ -207,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/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx index b2922b7..7ed1215 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -1,4 +1,5 @@ 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, ServerSegment } from "../../../../../stores/transcriptionStore"; import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService"; @@ -92,6 +93,7 @@ export const CCTranscriptionPanel: React.FC = () => { // Modal state const [showSettingsModal, setShowSettingsModal] = useState(false); + const { user: authUser } = useAuth(); const [showSummaryModal, setShowSummaryModal] = useState(false); const [summaryType, setSummaryType] = useState('full_lesson'); @@ -99,11 +101,13 @@ export const CCTranscriptionPanel: React.FC = () => { const [newKeyword, setNewKeyword] = useState(''); const [isAddingKeyword, setIsAddingKeyword] = useState(false); - // Load sessions and keyword watches on mount + // Load sessions when auth is confirmed (avoids GoTrueClient lock on mount) useEffect(() => { - loadSessions().then(setSessions); - loadKeywordWatches(); - }, []); + if (authUser?.id) { + loadSessions().then(setSessions); + loadKeywordWatches(); + } + }, [authUser?.id]); // Auto-detect timetable context on mount useEffect(() => { From 3a65cf436bea29f1549715125e3afe6b22fe8579 Mon Sep 17 00:00:00 2001 From: kcar Date: Mon, 25 May 2026 15:13:37 +0100 Subject: [PATCH 5/7] fix(panels): eliminate getSession() from transcription store and cabinets panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same GoTrueClient lock contention fix as CCFilesPanel: - transcriptionStore: add _accessToken/_userId state + setAuthInfo() action; replace all 6 getSession() calls (startSession, flushCanvasEvents, loadSessions, loadKeywordWatches, addKeywordWatch, deleteKeywordWatch, checkSegmentForKeywords) with stored values β€” zero getSession() calls remain in the store - CCTranscriptionPanel: destructure accessToken from useAuth; sync both values into store via setAuthInfo() on every auth change; gate loadSessions on authUser.id - CCCabinetsPanel: same pattern as CCFilesPanel β€” useAuth for token, useCallback on apiFetch/loadCabinets, gate effect on authUser.id Co-Authored-By: Claude Sonnet 4.6 --- src/stores/transcriptionStore.ts | 49 ++++++++++++------- .../components/shared/CCCabinetsPanel.tsx | 25 +++++----- .../shared/CCTranscriptionPanel.tsx | 8 ++- 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts index 25455bb..c65137f 100644 --- a/src/stores/transcriptionStore.ts +++ b/src/stores/transcriptionStore.ts @@ -130,6 +130,11 @@ 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; @@ -167,6 +172,8 @@ export const useTranscriptionStore = create((set, get) => ({ isRecording: false, isConnecting: false, activeSession: null, + _accessToken: null, + _userId: null, completedSegments: [], serverWindow: [], currentSegment: null, @@ -195,19 +202,23 @@ export const useTranscriptionStore = create((set, get) => ({ set({ timetableContext: context }); }, + setAuthInfo: (token: string | null, userId: string | null) => { + set({ _accessToken: token, _userId: userId }); + }, + startSession: async (timetableTag?: TimetablePeriod) => { set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); // Create session in Supabase try { - const { data: sessionData_auth } = await supabase.auth.getSession(); - if (!sessionData_auth.session?.user) { + const { _accessToken: token, _userId: userId } = get(); + if (!token || !userId) { console.error('No authenticated user'); return; } const sessionData = { - user_id: sessionData_auth.session.user.id, + user_id: userId, title: timetableTag?.event_label || 'Untitled Session', canvas_type: 'teaching-canvas', timetable_period_id: timetableTag?.period_id || null, @@ -465,7 +476,7 @@ export const useTranscriptionStore = create((set, get) => ({ for (const event of eventsToFlush) { await supabase.from('canvas_events').insert({ session_id: activeSession?.id || null, - user_id: (await supabase.auth.getSession()).data.session?.user?.id || '', + user_id: get()._userId || '', timestamp: new Date().toISOString(), session_elapsed_seconds: event.sessionElapsedSeconds || null, event_type: event.eventType, @@ -484,13 +495,13 @@ export const useTranscriptionStore = create((set, get) => ({ loadSessions: async (): Promise => { try { - const { data: sessionData_auth } = await supabase.auth.getSession(); - if (!sessionData_auth.session?.user) return []; + const { _userId: userId } = get(); + if (!userId) return []; const { data, error } = await supabase .from('transcription_sessions') .select('*') - .eq('user_id', sessionData_auth.session.user.id) + .eq('user_id', userId) .order('started_at', { ascending: false }) .limit(50); @@ -584,11 +595,11 @@ export const useTranscriptionStore = create((set, get) => ({ loadKeywordWatches: async () => { try { - const { data: { session } } = await supabase.auth.getSession(); - if (!session?.access_token) return; + 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 ${session.access_token}` }, + headers: { 'Authorization': `Bearer ${token}` }, }); if (!response.ok) return; const watches = await response.json(); @@ -600,17 +611,17 @@ export const useTranscriptionStore = create((set, get) => ({ addKeywordWatch: async (keyword: string) => { try { - const { data: { session } } = await supabase.auth.getSession(); - if (!session?.access_token || !session.user) return; + 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 ${session.access_token}`, + 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ - user_id: session.user.id, + user_id: userId, keyword: keyword.trim(), match_type: 'contains', action: 'alert', @@ -626,12 +637,12 @@ export const useTranscriptionStore = create((set, get) => ({ deleteKeywordWatch: async (watchId: string) => { try { - const { data: { session } } = await supabase.auth.getSession(); - if (!session?.access_token) return; + 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 ${session.access_token}` }, + headers: { 'Authorization': `Bearer ${token}` }, }); set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); } catch (error) { @@ -667,13 +678,13 @@ export const useTranscriptionStore = create((set, get) => ({ if (activeSession) { try { - const { data: { session } } = await supabase.auth.getSession(); + 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', - ...(session?.access_token ? { 'Authorization': `Bearer ${session.access_token}` } : {}), + ...(_kwToken ? { 'Authorization': `Bearer ${_kwToken}` } : {}), }, body: JSON.stringify({ session_id: activeSession.id, 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/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx index 7ed1215..300b8a7 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -67,6 +67,7 @@ export const CCTranscriptionPanel: React.FC = () => { flushCanvasEvents, loadSessions, setTimetableContext, + setAuthInfo, llmConfig, summaryText, isGeneratingSummary, @@ -93,7 +94,7 @@ export const CCTranscriptionPanel: React.FC = () => { // Modal state const [showSettingsModal, setShowSettingsModal] = useState(false); - const { user: authUser } = useAuth(); + const { user: authUser, accessToken } = useAuth(); const [showSummaryModal, setShowSummaryModal] = useState(false); const [summaryType, setSummaryType] = useState('full_lesson'); @@ -101,6 +102,11 @@ export const CCTranscriptionPanel: React.FC = () => { const [newKeyword, setNewKeyword] = useState(''); const [isAddingKeyword, setIsAddingKeyword] = useState(false); + // Sync access token into Zustand store so all store actions can use it without getSession() + useEffect(() => { + setAuthInfo(accessToken, authUser?.id ?? null); + }, [accessToken, authUser?.id, setAuthInfo]); + // Load sessions when auth is confirmed (avoids GoTrueClient lock on mount) useEffect(() => { if (authUser?.id) { From b0c7758135d37e45b516e0c0af32bea931fcfc6e Mon Sep 17 00:00:00 2001 From: kcar Date: Tue, 26 May 2026 01:25:15 +0100 Subject: [PATCH 6/7] feat(phase-b): Supabase navigation store, snapshot service, auth wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/navigation/GraphNavigator.tsx | 487 ++------ src/components/navigation/GraphSidebar.tsx | 225 ++++ src/contexts/AuthContext.tsx | 95 +- src/contexts/NeoUserContext.tsx | 60 +- src/pages/tldraw/singlePlayerPage.tsx | 140 +-- src/services/tldraw/snapshotService.ts | 475 +++----- src/stores/navigationStore.ts | 647 +++++----- src/stores/transcriptionStore.ts | 1045 +++++++++-------- src/types/navigation.ts | 8 + .../cc-transcription/transcriptionService.tsx | 92 +- .../components/shared/LLMConfigModal.tsx | 259 +++- 11 files changed, 1641 insertions(+), 1892 deletions(-) create mode 100644 src/components/navigation/GraphSidebar.tsx diff --git a/src/components/navigation/GraphNavigator.tsx b/src/components/navigation/GraphNavigator.tsx index 353a1ff..2054946 100644 --- a/src/components/navigation/GraphNavigator.tsx +++ b/src/components/navigation/GraphNavigator.tsx @@ -1,458 +1,121 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react'; -import { - IconButton, - Tooltip, - Box, - Menu, - MenuItem, - ListItemIcon, - ListItemText, - Button, - styled +import React, { useState } from 'react'; +import { + IconButton, Tooltip, Box, Menu, MenuItem, + ListItemIcon, ListItemText, Chip, styled, } from '@mui/material'; -import { +import { ArrowBack as ArrowBackIcon, ArrowForward as ArrowForwardIcon, History as HistoryIcon, - School as SchoolIcon, - Person as PersonIcon, - AccountCircle as AccountCircleIcon, - CalendarToday as CalendarIcon, - School as TeachingIcon, - Business as BusinessIcon, - AccountTree as DepartmentIcon, - Class as ClassIcon, - ExpandMore as ExpandMoreIcon + Home as HomeIcon, + CalendarToday, + DateRange, + Event, + WorkspacesOutlined, } from '@mui/icons-material'; import { useNavigationStore } from '../../stores/navigationStore'; -import { useNeoUser } from '../../contexts/NeoUserContext'; -import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts'; -import { - BaseContext, - ViewContext -} from '../../types/navigation'; -import { logger } from '../../debugConfig'; const NavigationRoot = styled(Box)` display: flex; align-items: center; - gap: 8px; + gap: 6px; height: 100%; - overflow: hidden; `; -const NavigationControls = styled(Box)` - display: flex; - align-items: center; - gap: 4px; -`; - -const ContextToggleContainer = styled(Box)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - backgroundColor: theme.palette.action.hover, - borderRadius: theme.shape.borderRadius, - padding: theme.spacing(0.5), - gap: theme.spacing(0.5), - '& .button-label': { - '@media (max-width: 500px)': { - display: 'none' - } +function getNodeIcon(nodeType: string) { + switch (nodeType) { + case 'User': return ; + case 'CalendarYear': return ; + case 'CalendarMonth': return ; + case 'CalendarDay': return ; + default: return ; } -})); - -const ContextToggleButton = styled(Button, { - shouldForwardProp: (prop) => prop !== 'active' -})<{ active?: boolean }>(({ theme, active }) => ({ - minWidth: 0, - padding: theme.spacing(0.5, 1.5), - borderRadius: theme.shape.borderRadius, - backgroundColor: active ? theme.palette.primary.main : 'transparent', - color: active ? theme.palette.primary.contrastText : theme.palette.text.primary, - textTransform: 'none', - transition: theme.transitions.create(['background-color', 'color'], { - duration: theme.transitions.duration.shorter, - }), - '&:hover': { - backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover, - }, - '@media (max-width: 500px)': { - padding: theme.spacing(0.5), - } -})); +} export const GraphNavigator: React.FC = () => { - const { - context, - switchContext, - goBack, - goForward, - isLoading - } = useNavigationStore(); - - const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser(); - - const [contextMenuAnchor, setContextMenuAnchor] = useState(null); + const { context, goBack, goForward, isLoading } = useNavigationStore(); const [historyMenuAnchor, setHistoryMenuAnchor] = useState(null); - const rootRef = useRef(null); - const [availableWidth, setAvailableWidth] = useState(0); - useEffect(() => { - const calculateAvailableSpace = () => { - if (!rootRef.current) return; - - // Get the header element - const header = rootRef.current.closest('.MuiToolbar-root'); - if (!header) return; - - // Get the title and menu elements - const title = header.querySelector('.app-title'); - const menu = header.querySelector('.menu-button'); - - if (!title || !menu) return; - - // Calculate available width - const headerWidth = header.clientWidth; - const titleWidth = title.clientWidth; - const menuWidth = menu.clientWidth; - const padding = 48; // Increased buffer space - - const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding; - console.log('Available width:', newAvailableWidth); // Debug log - setAvailableWidth(newAvailableWidth); - }; - - // Set up ResizeObserver - const resizeObserver = new ResizeObserver(() => { - // Use requestAnimationFrame to debounce calculations - window.requestAnimationFrame(calculateAvailableSpace); - }); - - // Observe both the root element and the header - if (rootRef.current) { - const header = rootRef.current.closest('.MuiToolbar-root'); - if (header) { - resizeObserver.observe(header); - resizeObserver.observe(rootRef.current); - } - } - - // Initial calculation - calculateAvailableSpace(); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - // Helper function to determine what should be visible - const getVisibility = () => { - // Adjusted thresholds and collapse order: - // 1. Navigation controls (back/forward/history) collapse first - // 2. Toggle labels collapse second - // 3. Context label collapses last - if (availableWidth < 300) { - return { - navigation: false, - contextLabel: true, // Keep context label visible longer - toggleLabels: false - }; - } else if (availableWidth < 450) { - return { - navigation: false, - contextLabel: true, // Keep context label visible - toggleLabels: true - }; - } else if (availableWidth < 600) { - return { - navigation: true, - contextLabel: true, - toggleLabels: true - }; - } - return { - navigation: true, - contextLabel: true, - toggleLabels: true - }; - }; - - const visibility = getVisibility(); - - const handleHistoryClick = (event: React.MouseEvent) => { - setHistoryMenuAnchor(event.currentTarget); - }; - - const handleHistoryClose = () => { - setHistoryMenuAnchor(null); - }; - - const handleHistoryItemClick = (index: number) => { - const {currentIndex} = context.history; - const steps = index - currentIndex; - - if (steps < 0) { - for (let i = 0; i < -steps; i++) { - goBack(); - } - } else if (steps > 0) { - for (let i = 0; i < steps; i++) { - goForward(); - } - } - - handleHistoryClose(); - }; - - const handleContextChange = useCallback(async (newContext: BaseContext) => { - try { - // Check if trying to access institute contexts without worker database - if (['school', 'department', 'class'].includes(newContext) && !workerDbName) { - logger.error('navigation', '❌ Cannot switch to institute context: missing worker database'); - return; - } - // Check if trying to access profile contexts without user database - if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) { - logger.error('navigation', '❌ Cannot switch to profile context: missing user database'); - return; - } - - logger.debug('navigation', 'πŸ”„ Changing main context', { - from: context.main, - to: newContext, - userDbName, - workerDbName - }); - - // Get default view for new context - const defaultView = getDefaultViewForContext(newContext); - - // Use unified context switch with both base and extended contexts - await switchContext({ - main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute', - base: newContext, - extended: defaultView, - skipBaseContextLoad: false - }, userDbName, workerDbName); - - } catch (error) { - logger.error('navigation', '❌ Failed to change context:', error); - } - }, [context.main, switchContext, userDbName, workerDbName]); - - // Helper function to get default view for a context - const getDefaultViewForContext = (context: BaseContext): ViewContext => { - switch (context) { - case 'calendar': - return 'overview'; - case 'teaching': - return 'overview'; - case 'school': - return 'overview'; - case 'department': - return 'overview'; - case 'class': - return 'overview'; - default: - return 'overview'; - } - }; - - const handleContextMenu = (event: React.MouseEvent) => { - setContextMenuAnchor(event.currentTarget); - }; - - const handleContextSelect = useCallback(async (context: BaseContext) => { - setContextMenuAnchor(null); - try { - // Use unified context switch with both base and extended contexts - const contextDef = NAVIGATION_CONTEXTS[context]; - const defaultExtended = contextDef?.views[0]?.id; - - await switchContext({ - base: context, - extended: defaultExtended - }, userDbName, workerDbName); - } catch (error) { - logger.error('navigation', '❌ Failed to select context:', error); - } - }, [switchContext, userDbName, workerDbName]); - - const getContextItems = useCallback(() => { - if (context.main === 'profile') { - return [ - { id: 'profile', label: 'Profile', icon: AccountCircleIcon }, - { id: 'calendar', label: 'Calendar', icon: CalendarIcon }, - { id: 'teaching', label: 'Teaching', icon: TeachingIcon }, - ]; - } else { - return [ - { id: 'school', label: 'School', icon: BusinessIcon }, - { id: 'department', label: 'Department', icon: DepartmentIcon }, - { id: 'class', label: 'Class', icon: ClassIcon }, - ]; - } - }, [context.main]); - - const getContextIcon = useCallback((contextType: string) => { - switch (contextType) { - case 'profile': - return ; - case 'calendar': - return ; - case 'teaching': - return ; - case 'school': - return ; - case 'department': - return ; - case 'class': - return ; - default: - return ; - } - }, []); - - const isDisabled = !isNeoUserInitialized || isLoading; const { history } = context; const canGoBack = history.currentIndex > 0; const canGoForward = history.currentIndex < history.nodes.length - 1; + const currentNode = context.node; + + const handleHistoryClick = (e: React.MouseEvent) => setHistoryMenuAnchor(e.currentTarget); + const handleHistoryClose = () => setHistoryMenuAnchor(null); + const handleHistoryItemClick = (index: number) => { + const delta = index - history.currentIndex; + if (delta < 0) for (let i = 0; i < -delta; i++) goBack(); + else if (delta > 0) for (let i = 0; i < delta; i++) goForward(); + handleHistoryClose(); + }; return ( - - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - + + + + + + + + + {currentNode && ( + + )} - {/* History Menu */} {history.nodes.map((node, index) => ( handleHistoryItemClick(index)} selected={index === history.currentIndex} + dense > - - {getContextIcon(node.type)} + + {getNodeIcon(node.type)} - ))} - - - handleContextChange('profile' as BaseContext)} - startIcon={} - disabled={isDisabled || !userDbName} - > - {visibility.toggleLabels && Profile} - - handleContextChange('school' as BaseContext)} - startIcon={} - disabled={isDisabled || !workerDbName} - > - {visibility.toggleLabels && Institute} - - - - - - - - - - - - setContextMenuAnchor(null)} - > - {getContextItems().map(item => ( - handleContextSelect(item.id as BaseContext)} - disabled={isDisabled} - > - - - - - - ))} - ); -}; \ No newline at end of file +}; diff --git a/src/components/navigation/GraphSidebar.tsx b/src/components/navigation/GraphSidebar.tsx new file mode 100644 index 0000000..1981e8b --- /dev/null +++ b/src/components/navigation/GraphSidebar.tsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, IconButton, CircularProgress, Tooltip, Collapse, +} from '@mui/material'; +import { + ChevronLeft, ChevronRight, + ExpandMore, ChevronRight as ChevronRightIcon, + Home as HomeIcon, CalendarToday, DateRange, Event, +} from '@mui/icons-material'; +import { useNavigationStore } from '../../stores/navigationStore'; +import { useAuth } from '../../contexts/AuthContext'; +import { NeoGraphNode } from '../../types/navigation'; +import { logger } from '../../debugConfig'; + +interface TreeNode extends NeoGraphNode { + has_children?: boolean; + children?: TreeNode[]; +} + +const NODE_ICONS: Record = { + User: HomeIcon, + CalendarYear: CalendarToday, + CalendarMonth: DateRange, + CalendarWeek: DateRange, + CalendarDay: Event, +}; + +const SIDEBAR_WIDTH = 220; + +interface TreeItemProps { + node: TreeNode; + depth: number; + onSelect: (node: TreeNode) => void; + onExpand: (node: TreeNode) => Promise; + activeRoomId?: string; +} + +function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) { + const [expanded, setExpanded] = useState(false); + const [children, setChildren] = useState(node.children || []); + const [loading, setLoading] = useState(false); + + const Icon = NODE_ICONS[node.node_type] || HomeIcon; + const canExpand = node.has_children !== false && node.node_type !== 'CalendarDay'; + + const handleToggle = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!expanded && children.length === 0 && canExpand) { + setLoading(true); + try { + const loaded = await onExpand(node); + setChildren(loaded); + } finally { + setLoading(false); + } + } + setExpanded(v => !v); + }; + + return ( + + onSelect(node)} + sx={{ + display: 'flex', alignItems: 'center', + pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.4, + cursor: 'pointer', borderRadius: 1, mx: 0.5, + fontSize: '0.78rem', minHeight: 28, + bgcolor: 'transparent', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + {canExpand && ( + loading + ? + : ( + + {expanded + ? + : } + + ) + )} + + + + {node.label} + + + {canExpand && ( + + {children.map(child => ( + + ))} + + )} + + ); +} + +interface GraphSidebarProps { + open: boolean; + onToggle: () => void; +} + +export function GraphSidebar({ open, onToggle }: GraphSidebarProps) { + const { accessToken } = useAuth(); + const { navigateToNeoNode, context } = useNavigationStore(); + const [tree, setTree] = useState(null); + const [loading, setLoading] = useState(false); + + const apiBase = import.meta.env.VITE_API_BASE as string; + + const fetchTree = useCallback(async () => { + if (!accessToken) return; + setLoading(true); + try { + const res = await fetch(`${apiBase}/graph/tree`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) throw new Error(`Graph tree fetch failed: ${res.status}`); + const data = await res.json(); + setTree(data.tree); + } catch (err) { + logger.error('graph-sidebar', 'Failed to load graph tree', err); + } finally { + setLoading(false); + } + }, [accessToken, apiBase]); + + useEffect(() => { + if (open && !tree && accessToken) fetchTree(); + }, [open, tree, accessToken, fetchTree]); + + const handleExpand = useCallback(async (node: TreeNode): Promise => { + if (!accessToken) return []; + const params = new URLSearchParams({ + neo4j_node_id: node.neo4j_node_id, + neo4j_db_name: node.neo4j_db_name, + node_type: node.node_type, + }); + try { + const res = await fetch(`${apiBase}/graph/node/children?${params}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) return []; + const data = await res.json(); + return data.children || []; + } catch { + return []; + } + }, [accessToken, apiBase]); + + return ( + + + + {loading ? ( + + + + ) : tree ? ( + navigateToNeoNode(n)} + onExpand={handleExpand} + activeRoomId={context.node?.id} + /> + ) : null} + + + + + {open + ? + : } + + + + ); +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 9886446..bbf2a6b 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -33,9 +33,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [user_role, setUserRole] = useState(null); const [accessToken, setAccessToken] = useState(null); - const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const apiBase = import.meta.env.VITE_API_BASE as string; + const persistSession = useCallback((session: Session | null) => { if (session) { storageService.set(StorageKeys.SUPABASE_SESSION, session); @@ -69,57 +71,82 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return { user: resolvedUser, role: resolvedRole }; }, []); + const triggerUserInit = useCallback((token: string) => { + fetch(`${apiBase}/user/init`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + .then(r => r.json()) + .then(data => logger.debug('auth-context', 'βœ… User init', data)) + .catch(err => logger.warn('auth-context', '⚠️ User init failed', { err })); + }, [apiBase]); + useEffect(() => { - // Canonical Supabase auth pattern: rely solely on onAuthStateChange. - // INITIAL_SESSION fires immediately with the current session state, - // eliminating the race condition between loadInitialSession + onAuthStateChange. const { data: { subscription } } = supabase.auth.onAuthStateChange( async (event, session) => { logger.debug('auth-context', 'πŸ”„ Auth state change', { event, hasSession: !!session }); - switch (event) { - case 'INITIAL_SESSION': - case 'SIGNED_IN': - case 'TOKEN_REFRESHED': { - persistSession(session ?? null); - if (session?.user) { - try { - const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); - setUser(resolvedUser); - setUserRole(role); - setAccessToken(session.access_token ?? null); - } catch (buildError) { - logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); - setUser(null); - setUserRole(null); - setAccessToken(null); - setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); - } - } else { + if (event === 'SIGNED_IN') { + persistSession(session ?? null); + if (session?.user) { + try { + const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); + setUser(resolvedUser); + setUserRole(role); + setAccessToken(session.access_token ?? null); + triggerUserInit(session.access_token); + } catch (buildError) { + logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); setUser(null); setUserRole(null); setAccessToken(null); + setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); } - // Always clear loading after the first auth event resolves - setLoading(false); - break; - } - case 'SIGNED_OUT': { - persistSession(null); + } else { setUser(null); setUserRole(null); setAccessToken(null); - setLoading(false); - break; } - default: - break; + setLoading(false); + return; + } + + if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') { + persistSession(session ?? null); + if (session?.user) { + try { + const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); + setUser(resolvedUser); + setUserRole(role); + setAccessToken(session.access_token ?? null); + } catch (buildError) { + logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); + setUser(null); + setUserRole(null); + setAccessToken(null); + setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); + } + } else { + setUser(null); + setUserRole(null); + setAccessToken(null); + } + setLoading(false); + return; + } + + if (event === 'SIGNED_OUT') { + persistSession(null); + setUser(null); + setUserRole(null); + setAccessToken(null); + setLoading(false); } } ); return () => subscription.unsubscribe(); - }, [buildUserFromSupabase, persistSession]); + }, [buildUserFromSupabase, persistSession, triggerUserInit]); const signIn = async (email: string, password: string) => { try { diff --git a/src/contexts/NeoUserContext.tsx b/src/contexts/NeoUserContext.tsx index ffb6ce5..7ac68c8 100644 --- a/src/contexts/NeoUserContext.tsx +++ b/src/contexts/NeoUserContext.tsx @@ -3,7 +3,6 @@ import { useAuth } from './AuthContext'; import { useUser } from './UserContext'; import { logger } from '../debugConfig'; import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types'; -import { DatabaseNameService } from '../services/graph/databaseNameService'; import { CalendarStructure, WorkerStructure } from '../types/navigation'; import { useNavigationStore } from '../stores/navigationStore'; @@ -131,7 +130,7 @@ const NeoUserContext = createContext({ }); export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const { user } = useAuth(); + const { user, accessToken } = useAuth(); const { profile, isInitialized: isUserInitialized } = useUser(); const navigationStore = useNavigationStore(); @@ -215,12 +214,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) setIsLoading(true); setError(null); - // Set database names - const userDb = profile.user_db_name || (user?.email ? - DatabaseNameService.getStoredUserDatabase() || null : null); - - if (!userDb) { - throw new Error('No user database name available'); + // Inject auth into navigation store so Supabase queries work + if (user?.id && accessToken) { + navigationStore.setAuthInfo(accessToken, user.id); } // Initialize user node in profile context @@ -236,7 +232,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) main: 'profile', base: 'profile', extended: 'overview' - }, userDb, profile.school_db_name), + }, null, null), switchTimeout ]); @@ -271,9 +267,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) // Continue without user node - this is not critical for basic functionality } - // Set final state - setUserDbName(userDb); - setWorkerDbName(profile.school_db_name); + // Set final state β€” userDbName signals auth availability for UI guards + setUserDbName(user?.id || null); + setWorkerDbName(null); setIsInitialized(true); setIsLoading(false); initializationRef.current.isComplete = true; @@ -294,13 +290,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) // Calendar Navigation Functions const navigateToDay = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'calendar', extended: 'day' - }, userDbName, workerDbName); + }, null, null); const node = navigationStore.context.node; if (node?.data) { @@ -334,13 +330,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) }; const navigateToWeek = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'calendar', extended: 'week' - }, userDbName, workerDbName); + }, null, null); const node = navigationStore.context.node; if (node?.data) { @@ -374,13 +370,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) }; const navigateToMonth = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'calendar', extended: 'month' - }, userDbName, workerDbName); + }, null, null); const node = navigationStore.context.node; if (node?.data) { @@ -414,13 +410,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) }; const navigateToYear = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'calendar', extended: 'year' - }, userDbName, workerDbName); + }, null, null); const node = navigationStore.context.node; if (node?.data) { @@ -455,13 +451,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) // Worker Navigation Functions const navigateToTimetable = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'teaching', extended: 'timetable' - }, userDbName, workerDbName); + }, null, null); const node = navigationStore.context.node; if (node?.data) { @@ -492,13 +488,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) }; const navigateToJournal = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'teaching', extended: 'journal' - }, userDbName, workerDbName); + }, null, null); const node = navigationStore.context.node; if (node?.data) { @@ -529,13 +525,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) }; const navigateToPlanner = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'teaching', extended: 'planner' - }, userDbName, workerDbName); + }, null, null); const node = navigationStore.context.node; if (node?.data) { @@ -566,14 +562,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) }; const navigateToClass = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'teaching', extended: 'classes' - }, userDbName, workerDbName); - await navigationStore.navigate(id, userDbName); + }, null, null); + await navigationStore.navigate(id, ''); const node = navigationStore.context.node; if (node?.data) { @@ -604,14 +600,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) }; const navigateToLesson = async (id: string) => { - if (!userDbName) return; + if (!user?.id) return; setIsLoading(true); try { await navigationStore.switchContext({ base: 'teaching', extended: 'lessons' - }, userDbName, workerDbName); - await navigationStore.navigate(id, userDbName); + }, null, null); + await navigationStore.navigate(id, ''); const node = navigationStore.context.node; if (node?.data) { diff --git a/src/pages/tldraw/singlePlayerPage.tsx b/src/pages/tldraw/singlePlayerPage.tsx index 5ccbb67..4ab47fa 100644 --- a/src/pages/tldraw/singlePlayerPage.tsx +++ b/src/pages/tldraw/singlePlayerPage.tsx @@ -10,11 +10,11 @@ import { TLStoreWithStatus } from '@tldraw/tldraw'; import { useTLDraw } from '../../contexts/TLDrawContext'; +import { useAuth } from '../../contexts/AuthContext'; import { useUser } from '../../contexts/UserContext'; // Tldraw services import { localStoreService } from '../../services/tldraw/localStoreService'; import { PresentationService } from '../../services/tldraw/presentationService'; -import { UserNeoDBService } from '../../services/graph/userNeoDBService'; import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService'; import { NavigationSnapshotService } from '../../services/tldraw/snapshotService'; // Tldraw utils @@ -46,6 +46,8 @@ interface LoadingState { export default function SinglePlayerPage() { // Context hooks with initialization states const { profile: user, loading: userLoading } = useUser(); + const { accessToken } = useAuth(); + const { context, setAuthInfo, switchContext } = useNavigationStore(); const { tldrawPreferences, initializePreferences, @@ -55,8 +57,6 @@ export default function SinglePlayerPage() { const routerNavigate = useNavigate(); const location = useLocation(); - // Navigation store - const { context } = useNavigationStore(); // Refs const editorRef = useRef(null); @@ -114,6 +114,7 @@ export default function SinglePlayerPage() { // 2. Initialize snapshot service const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined); + if (accessToken) snapshotService.setAccessToken(accessToken); snapshotServiceRef.current = snapshotService; logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService'); @@ -131,12 +132,14 @@ export default function SinglePlayerPage() { await NavigationSnapshotService.loadNodeSnapshotFromDatabase( nodeStoragePath, - null, + accessToken || '', newStore, setLoadingState, - undefined, // sharedStore - editorRef.current || undefined // editor + undefined, + editorRef.current || undefined ); + // Wire auto-save: set the current path on the service instance + snapshotService.setCurrentNodePath(nodeStoragePath); logger.debug('single-player-page', 'βœ… Snapshot loaded from database'); } else { logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', { @@ -152,7 +155,7 @@ export default function SinglePlayerPage() { let isAutoSaving = false; newStore.listen(() => { - if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) { + if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) { // Skip if already saving if (isAutoSaving) { logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving'); @@ -178,8 +181,6 @@ export default function SinglePlayerPage() { isAutoSaving = false; } }, 2000); // Increased to 2 seconds debounce - } else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) { - logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet'); } }); @@ -253,11 +254,16 @@ export default function SinglePlayerPage() { try { setLoadingState({ status: 'loading', error: '' }); - - // Center the node - const nodeData = await loadNodeData(context.node); - await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData); - + + if (context.node.type !== 'workspace') { + try { + const nodeData = await loadNodeData(context.node); + await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData); + } catch (shapeErr) { + logger.warn('single-player-page', '⚠️ Could not place node shape', { type: context.node.type, error: shapeErr }); + } + } + setIsInitialLoad(false); setLoadingState({ status: 'ready', error: '' }); } catch (error) { @@ -297,12 +303,17 @@ export default function SinglePlayerPage() { ? context.history.nodes[context.history.currentIndex - 1] : null; - // Handle navigation in snapshot service + // Handle navigation in snapshot service (load/save snapshot) await snapshotService.handleNavigationStart(previousNode, currentNode); - // Center the node on canvas - const nodeData = await loadNodeData(currentNode); - await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData); + if (currentNode.type !== 'workspace') { + try { + const nodeData = await loadNodeData(currentNode); + await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData); + } catch (shapeErr) { + logger.warn('single-player-page', '⚠️ Could not place node shape', { type: currentNode.type, error: shapeErr }); + } + } setLoadingState({ status: 'ready', error: '' }); } catch (error) { @@ -315,7 +326,17 @@ export default function SinglePlayerPage() { }; handleNodeChange(); - }, [context.node, context.history, store, isInitialLoad]); + }, [context.node, context.history, store]); + + // Inject auth and trigger initial context when token is ready + useEffect(() => { + if (user?.id && accessToken) { + setAuthInfo(accessToken, user.id); + if (!context.node) { + switchContext({ main: 'profile', base: 'profile' }, null, null); + } + } + }, [user?.id, accessToken]); // Initialize preferences when user is available useEffect(() => { @@ -462,9 +483,6 @@ export default function SinglePlayerPage() { position: 'fixed', inset: 0, top: `${HEADER_HEIGHT}px`, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden' }}> {/* Loading overlay - show when loading or contexts not initialized */} {(loadingState.status === 'loading' || !store) && ( @@ -527,6 +545,7 @@ export default function SinglePlayerPage() { // Update snapshot service with editor reference if (snapshotServiceRef.current) { snapshotServiceRef.current.setEditor(editor); + if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken); } setIsEditorReady(true); @@ -565,68 +584,21 @@ const getNodeStoragePath = (node: NavigationNode): string | null => { }; const loadNodeData = async (node: NavigationNode): Promise => { - // Validate the node parameter - if (!node) { - throw new Error('Node parameter is required'); - } - - if (!node.id) { - throw new Error('Node must have an ID'); - } - + if (!node?.id) throw new Error('Node parameter is required'); const nodeStoragePath = getNodeStoragePath(node); - if (!nodeStoragePath) { - throw new Error(`Node ${node.id} is missing node_storage_path`); - } - - logger.debug('single-player-page', 'πŸ”„ Loading node data', { - nodeId: node.id, - nodeType: node.type, - nodeLabel: node.label, - nodeStoragePath: nodeStoragePath - }); + if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`); - try { - // 1. Always fetch fresh data - // Create a temporary node object with the correct structure for the service - const normalizedNode = { - ...node, - node_storage_path: nodeStoragePath - }; - - const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode); - const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName); - - if (!fetchedData?.node_data) { - throw new Error('Failed to fetch node data'); - } - - // 2. Process the data into the correct shape - const theme = getThemeFromLabel(node.type); - return { - ...fetchedData.node_data, - title: String(fetchedData.node_data.title || node.label || ''), - w: 500, - h: 350, - state: { - parentId: null, - isPageChild: true, - hasChildren: null, - bindings: null - }, - headerColor: theme.headerColor, - backgroundColor: theme.backgroundColor, - isLocked: false, - __primarylabel__: node.type, - uuid_string: node.id, - node_storage_path: nodeStoragePath - }; - } catch (error) { - logger.error('single-player-page', '❌ Error in loadNodeData', { - nodeId: node.id, - nodeType: node.type, - error: error instanceof Error ? error.message : String(error) - }); - throw error; - } + const theme = getThemeFromLabel(node.type); + return { + title: node.label || node.type || '', + w: 500, + h: 350, + state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null }, + headerColor: theme.headerColor, + backgroundColor: theme.backgroundColor, + isLocked: false, + __primarylabel__: node.type, + uuid_string: node.id, + node_storage_path: nodeStoragePath, + }; }; diff --git a/src/services/tldraw/snapshotService.ts b/src/services/tldraw/snapshotService.ts index b6b3b64..ef5ba6c 100644 --- a/src/services/tldraw/snapshotService.ts +++ b/src/services/tldraw/snapshotService.ts @@ -1,27 +1,52 @@ // External imports import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw'; -import axios from '../../axiosConfig'; import logger from '../../debugConfig'; import { SharedStoreService } from './sharedStoreService'; -import { StorageKeys, storageService } from '../auth/localStorageService'; -import { NavigationNode } from '../../types/navigation'; export interface LoadingState { status: 'loading' | 'ready' | 'error'; error: string; } -const EMPTY_NODE: NavigationNode = { - id: '', - node_storage_path: '', - type: '', - label: '' -}; +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string; +const BUCKET = 'cc.users'; + +async function storageGet(path: string, accessToken: string): Promise { + const url = `${SUPABASE_URL}/storage/v1/object/authenticated/${BUCKET}/${path}`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + apikey: SUPABASE_ANON_KEY, + }, + }); + if (res.status === 404 || res.status === 400) return null; + if (!res.ok) throw new Error(`Storage GET ${res.status}: ${await res.text()}`); + return res.json(); +} + +async function storagePut(path: string, accessToken: string, data: unknown): Promise { + const url = `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${path}`; + const headers = { + Authorization: `Bearer ${accessToken}`, + apikey: SUPABASE_ANON_KEY, + 'Content-Type': 'application/json', + }; + const body = JSON.stringify(data); + // PUT replaces an existing object; POST creates a new one. + // Avoids x-upsert custom header which self-hosted Supabase CORS may block. + let res = await fetch(url, { method: 'PUT', headers, body }); + if (!res.ok && (res.status === 404 || res.status === 400)) { + res = await fetch(url, { method: 'POST', headers, body }); + } + if (!res.ok) throw new Error(`Storage ${res.status}: ${await res.text()}`); +} export class NavigationSnapshotService { private store: TLStore; private editor: Editor | null = null; private currentNodePath: string | null = null; + private _accessToken: string | null = null; private isAutoSaveEnabled = true; private isSaving = false; private isLoading = false; @@ -33,24 +58,21 @@ export class NavigationSnapshotService { this.editor = editor || null; logger.debug('snapshot-service', 'πŸ”„ Initialized NavigationSnapshotService', { storeId: store.id, - hasEditor: !!editor + hasEditor: !!editor, }); } setEditor(editor: Editor): void { this.editor = editor; - logger.debug('snapshot-service', 'πŸ”„ Editor reference updated', { - editorId: editor.store.id - }); } - private static replaceBackslashes(input: string | undefined): string { - return input ? input.replace(/\\/g, '/') : ''; + setAccessToken(token: string): void { + this._accessToken = token; } static async loadNodeSnapshotFromDatabase( nodePath: string, - dbName: string, + accessToken: string, store: TLStore, setLoadingState: (state: LoadingState) => void, sharedStore?: SharedStoreService, @@ -58,252 +80,102 @@ export class NavigationSnapshotService { ): Promise { try { setLoadingState({ status: 'loading', error: '' }); + logger.info('snapshot-service', 'πŸ“‚ Loading snapshot from Storage', { path: nodePath }); - logger.info('snapshot-service', 'πŸ“‚ Loading file from path', { - path: nodePath, - db_name: dbName - }); + const snapshot = await storageGet(nodePath, accessToken); - const response = await axios.get( - '/database/tldraw_supabase/get_tldraw_node_file', { - params: { - path: this.replaceBackslashes(nodePath), - db_name: dbName + if (!snapshot) { + logger.debug('snapshot-service', 'ℹ️ No snapshot found at path β€” clearing canvas', { nodePath }); + // Clear all shapes so the canvas is blank for this new node + if (editor) { + const shapeIds = [...editor.getCurrentPageShapeIds()]; + if (shapeIds.length > 0) { + editor.deleteShapes(shapeIds); } } - ); - - const snapshot = response.data; - logger.debug('snapshot-service', 'πŸ” Snapshot data received', { - hasSnapshot: !!snapshot, - hasDocument: !!snapshot?.document, - hasSession: !!snapshot?.session, - hasSchemaVersion: !!snapshot?.schemaVersion, - schemaVersion: snapshot?.schemaVersion, - snapshotKeys: snapshot ? Object.keys(snapshot) : [] - }); - - if (snapshot && snapshot.document && snapshot.session) { - logger.debug('snapshot-service', 'πŸ“₯ Snapshot loaded successfully'); - - if (sharedStore) { - await sharedStore.loadSnapshot(snapshot, setLoadingState); - } else { - logger.debug('snapshot-service', 'πŸ”„ Calling TLDraw loadSnapshot', { - hasStore: !!store, - snapshotType: typeof snapshot, - snapshotKeys: Object.keys(snapshot), - snapshotSchemaVersion: snapshot?.schemaVersion, - snapshotDocument: !!snapshot?.document, - snapshotSession: !!snapshot?.session - }); - - // Create a defensive copy to ensure the snapshot doesn't get modified - const snapshotCopy = { - schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion, - document: snapshot.document, - session: snapshot.session - }; - - logger.debug('snapshot-service', 'πŸ”„ Calling loadSnapshot with defensive copy', { - copySchemaVersion: snapshotCopy.schemaVersion, - copyDocument: !!snapshotCopy.document, - copySession: !!snapshotCopy.session, - storeType: typeof store, - storeIsNull: store === null, - storeIsUndefined: store === undefined, - storeKeys: store ? Object.keys(store) : 'N/A' - }); - - // Debug: Log the snapshot schema sequences - if (snapshotCopy.document?.schema?.sequences) { - logger.debug('snapshot-service', 'πŸ” Snapshot schema sequences:', snapshotCopy.document.schema.sequences); - const customSequences = Object.keys(snapshotCopy.document.schema.sequences).filter(key => key.includes('cc-')); - logger.debug('snapshot-service', 'πŸ” Custom shape sequences in snapshot:', customSequences); - } - - // Debug: Log the store schema sequences - if (store?.schema) { - const storeSequences = store.schema.serialize().sequences; - logger.debug('snapshot-service', 'πŸ” Store schema sequences:', storeSequences); - const storeCustomSequences = Object.keys(storeSequences).filter(key => key.includes('cc-')); - logger.debug('snapshot-service', 'πŸ” Custom shape sequences in store:', storeCustomSequences); - } - - // Add try-catch around the loadSnapshot call to get more specific error info - try { - // Ensure store is properly initialized before loading snapshot - if (!store) { - throw new Error('Store is null or undefined'); - } - - // Validate snapshot structure before loading - if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) { - throw new Error('Invalid snapshot structure'); - } - - // Check for schema migrations and handle them properly - logger.debug('snapshot-service', 'πŸ”„ Checking for schema migrations', { - storeId: store.id, - storeType: typeof store, - storeConstructor: store.constructor.name, - snapshotSchemaVersion: snapshotCopy.schemaVersion, - snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}), - snapshotSessionKeys: Object.keys(snapshotCopy.session || {}) - }); - - try { - // Try to load the snapshot directly first - logger.debug('snapshot-service', 'πŸ”„ Attempting to load snapshot directly'); - if (editor) { - loadSnapshot(editor.store, snapshotCopy); - logger.debug('snapshot-service', 'βœ… Snapshot loaded successfully'); - } else { - // Fallback: use global loadSnapshot if no editor available - logger.debug('snapshot-service', 'πŸ”„ No editor available, using global loadSnapshot'); - loadSnapshot(store, snapshotCopy); - logger.debug('snapshot-service', 'βœ… Snapshot loaded successfully via global loadSnapshot'); - } - } catch (migrationError) { - // Check if this is a schema migration error that we can safely ignore - const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError); - const isSchemaMigrationError = errorMessage.includes('migration') || - errorMessage.includes('schema') || - errorMessage.includes('Incompatible'); - - if (isSchemaMigrationError) { - logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { - error: errorMessage - }); - // Continue with empty store - this is expected for some snapshots - } else { - logger.warn('snapshot-service', '⚠️ Unexpected load error', { - error: errorMessage - }); - } - } - - logger.debug('snapshot-service', 'βœ… loadSnapshot call succeeded'); - setLoadingState({ status: 'ready', error: '' }); - } catch (loadError) { - logger.error('snapshot-service', '❌ loadSnapshot call failed', { - error: loadError instanceof Error ? loadError.message : String(loadError), - storeType: typeof store, - storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function', - snapshotType: typeof snapshotCopy, - snapshotKeys: Object.keys(snapshotCopy) - }); - throw loadError; - } - storageService.set(StorageKeys.NODE_FILE_PATH, nodePath); - } - } else { - logger.error('snapshot-service', '❌ Invalid snapshot format'); - setLoadingState({ status: 'error', error: 'Invalid snapshot format' }); + setLoadingState({ status: 'ready', error: '' }); + return; } + + const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown }; + if (!snap.document || !snap.session) { + logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath }); + setLoadingState({ status: 'ready', error: '' }); + return; + } + + if (sharedStore) { + await sharedStore.loadSnapshot(snapshot, setLoadingState); + return; + } + + const snapshotCopy = { + schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion, + document: snap.document, + session: snap.session, + }; + + try { + if (editor) { + loadSnapshot(editor.store, snapshotCopy as Parameters[1]); + } else { + loadSnapshot(store, snapshotCopy as Parameters[1]); + } + logger.debug('snapshot-service', 'βœ… Snapshot loaded successfully'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const isSchemaMigration = /migration|schema|Incompatible/i.test(msg); + if (isSchemaMigration) { + logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg }); + } else { + logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg }); + } + } + + setLoadingState({ status: 'ready', error: '' }); } catch (error) { - logger.error('snapshot-service', '❌ Failed to fetch snapshot', { - error: error instanceof Error ? error.message : 'Unknown error' + logger.error('snapshot-service', '❌ Failed to load snapshot', { + error: error instanceof Error ? error.message : 'Unknown error', }); - setLoadingState({ - status: 'error', - error: error instanceof Error ? error.message : 'Failed to load file' + setLoadingState({ + status: 'error', + error: error instanceof Error ? error.message : 'Failed to load snapshot', }); } } static async saveNodeSnapshotToDatabase( nodePath: string, - dbName: string, + accessToken: string, store: TLStore ): Promise { try { - logger.info('snapshot-service', 'πŸ’Ύ Saving snapshot to database', { - path: nodePath, - db_name: dbName - }); - + logger.info('snapshot-service', 'πŸ’Ύ Saving snapshot to Storage', { path: nodePath }); const snapshot = getSnapshot(store); - - // Debug: Log what we're saving - logger.debug('snapshot-service', 'πŸ” Snapshot being saved:', { - hasSnapshot: !!snapshot, - snapshotKeys: Object.keys(snapshot || {}), - schemaVersion: snapshot?.schemaVersion, - hasDocument: !!snapshot?.document, - hasSession: !!snapshot?.session - }); - - // Debug: Log the schema sequences in the snapshot being saved - if (snapshot?.document?.schema?.sequences) { - logger.debug('snapshot-service', 'πŸ” Schema sequences being saved:', snapshot.document.schema.sequences); - const customSequences = Object.keys(snapshot.document.schema.sequences).filter(key => key.includes('cc-')); - logger.debug('snapshot-service', 'πŸ” Custom shape sequences being saved:', customSequences); - } - - const response = await axios.post( - '/database/tldraw_supabase/set_tldraw_node_file', - snapshot, - { - params: { - path: this.replaceBackslashes(nodePath), - db_name: dbName - } - } - ); - - if (response.data.status === 'success') { - logger.debug('snapshot-service', 'βœ… Snapshot saved successfully'); - } else { - throw new Error('Failed to save snapshot'); - } + await storagePut(nodePath, accessToken, snapshot); + logger.debug('snapshot-service', 'βœ… Snapshot saved successfully'); } catch (error) { - logger.error('snapshot-service', '❌ Failed to save snapshot', { - error: error instanceof Error ? error.message : 'Unknown error' + logger.error('snapshot-service', '❌ Failed to save snapshot', { + error: error instanceof Error ? error.message : 'Unknown error', }); throw error; } } private async saveCurrentSnapshot(nodePath: string): Promise { - if (!this.currentNodePath || this.currentNodePath !== nodePath) { - logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', { - currentPath: this.currentNodePath, - savePath: nodePath - }); + if (!this.currentNodePath || this.currentNodePath !== nodePath) return; + if (!this._accessToken) { + logger.debug('snapshot-service', '⚠️ No access token β€” snapshot save skipped'); return; } - try { this.isSaving = true; - const user = storageService.get(StorageKeys.USER); - if (!user) { - throw new Error('No user found'); - } - - const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? ''; - if (!dbName) { - logger.debug('snapshot-service', '⚠️ No db name - snapshot save skipped (Phase B will migrate to Supabase Storage)'); - return; - } - - logger.debug('snapshot-service', 'πŸ’Ύ Saving snapshot', { - nodePath, - dbName, - userType: user.user_type, - username: user.username - }); - - await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store); - - logger.debug('snapshot-service', 'βœ… Saved navigation snapshot', { - nodePath, - storeId: this.store.id - }); + await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store); + logger.debug('snapshot-service', 'βœ… Saved navigation snapshot', { nodePath }); } catch (error) { logger.error('snapshot-service', '❌ Failed to save navigation snapshot', { error: error instanceof Error ? error.message : 'Unknown error', - nodePath + nodePath, }); throw error; } finally { @@ -311,141 +183,77 @@ export class NavigationSnapshotService { } } - private async loadSnapshotForNode(node: NavigationNode): Promise { + private async loadSnapshotForNode(node: { node_storage_path: string }): Promise { + if (!this._accessToken) { + logger.debug('snapshot-service', '⚠️ No access token β€” snapshot load skipped'); + return; + } try { this.isLoading = true; - const user = storageService.get(StorageKeys.USER); - if (!user) { - throw new Error('No user found'); - } - - const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? ''; - if (!dbName) { - logger.debug('snapshot-service', '⚠️ No db name - snapshot load skipped (Phase B will migrate to Supabase Storage)'); - return; - } - - logger.debug('snapshot-service', 'πŸ“₯ Loading snapshot', { - nodePath: node.node_storage_path, - dbName, - userType: user.user_type, - username: user.username - }); - await NavigationSnapshotService.loadNodeSnapshotFromDatabase( node.node_storage_path, - dbName, + this._accessToken, this.store, (state: LoadingState) => { if (state.status === 'ready') { this.currentNodePath = node.node_storage_path; - logger.debug('snapshot-service', 'βœ… Snapshot loaded and path updated', { - nodePath: node.node_storage_path, - currentNodePath: this.currentNodePath - }); - } else if (state.status === 'error') { - logger.error('snapshot-service', '❌ Error in load callback', { - error: state.error, - nodePath: node.node_storage_path - }); } }, - undefined, // sharedStore - this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot + undefined, + this.editor || undefined ); - } catch (error) { - logger.error('snapshot-service', '❌ Failed to load navigation snapshot', { - error: error instanceof Error ? error.message : 'Unknown error', - nodePath: node.node_storage_path - }); - throw error; } finally { this.isLoading = false; } } - async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise { - if (!toNode) { - logger.warn('snapshot-service', '⚠️ Cannot navigate to null node'); - return; - } - - // Clear any pending debounce - if (this.debounceTimeout) { - clearTimeout(this.debounceTimeout); - } - - // Debounce the navigation operation + async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise { + if (!toNode) return; + if (this.debounceTimeout) clearTimeout(this.debounceTimeout); return new Promise((resolve) => { this.debounceTimeout = setTimeout(async () => { try { - await this.executeNavigation(fromNode || EMPTY_NODE, toNode); + await this.executeNavigation(fromNode, toNode); resolve(); } catch (error) { logger.error('snapshot-service', '❌ Navigation failed', error); throw error; } - }, 100); // 100ms debounce + }, 100); }); } - private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise { - try { - logger.debug('snapshot-service', 'πŸ”„ Starting navigation snapshot handling', { - from: fromNode.node_storage_path, - to: toNode.node_storage_path, - currentPath: this.currentNodePath - }); + private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise { + if (this.isSaving || this.isLoading) { + this.pendingOperation = { + save: fromNode?.node_storage_path, + load: toNode.node_storage_path, + }; + return; + } - // If we're already in a navigation operation, queue this one - if (this.isSaving || this.isLoading) { - this.pendingOperation = { - save: fromNode.node_storage_path || undefined, - load: toNode.node_storage_path - }; - logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation); - return; - } + this.currentNodePath = null; - // Clear the store before loading new snapshot - logger.debug('snapshot-service', 'πŸ”„ Clearing store'); - this.currentNodePath = null; - logger.debug('snapshot-service', '🧹 Cleared current node path'); + if (toNode.node_storage_path) { + await this.loadSnapshotForNode(toNode); + } - // Load the new node's snapshot - if (toNode.node_storage_path) { - await this.loadSnapshotForNode(toNode); - logger.debug('snapshot-service', 'βœ… Loaded new node snapshot', { - nodePath: toNode.node_storage_path - }); - } - - // Process any pending operations - if (this.pendingOperation) { - logger.debug('snapshot-service', 'πŸ”„ Processing pending operation', this.pendingOperation); - const operation = this.pendingOperation; - this.pendingOperation = null; - await this.handleNavigationStart( - operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null, - operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null - ); - logger.debug('snapshot-service', 'βœ… Completed pending operation'); - } - } catch (error) { - logger.error('snapshot-service', '❌ Error during navigation snapshot handling', { - error: error instanceof Error ? error.message : 'Unknown error', - fromPath: fromNode.node_storage_path, - toPath: toNode.node_storage_path - }); - throw error; + if (this.pendingOperation) { + const op = this.pendingOperation; + this.pendingOperation = null; + await this.handleNavigationStart( + op.save ? { node_storage_path: op.save } : null, + op.load ? { node_storage_path: op.load } : null + ); } } setAutoSave(enabled: boolean): void { this.isAutoSaveEnabled = enabled; - logger.debug('snapshot-service', 'πŸ”„ Auto-save setting changed', { - enabled - }); + } + + setCurrentNodePath(path: string): void { + this.currentNodePath = path; } getCurrentNodePath(): string | null { @@ -455,14 +263,11 @@ export class NavigationSnapshotService { async forceSaveCurrentNode(): Promise { if (this.currentNodePath) { await this.saveCurrentSnapshot(this.currentNodePath); - } else { - logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set'); } } clearCurrentNode(): void { this.currentNodePath = null; this.store.clear(); - logger.debug('snapshot-service', '🧹 Cleared current node and store'); } -} \ No newline at end of file +} diff --git a/src/stores/navigationStore.ts b/src/stores/navigationStore.ts index 52673c3..6b3493d 100644 --- a/src/stores/navigationStore.ts +++ b/src/stores/navigationStore.ts @@ -1,32 +1,45 @@ import { create } from 'zustand'; -import { UserNeoDBService } from '../services/graph/userNeoDBService'; import { logger } from '../debugConfig'; import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types'; -import { - NavigationStore, +import { + NavigationStore, NavigationNode, + NeoGraphNode, MainContext, BaseContext, NavigationContextState, isProfileContext, isInstituteContext, - getContextDatabase, addToHistory, navigateHistory, getCurrentHistoryNode, ExtendedContext, UnifiedContextSwitch, - NodeContext } from '../types/navigation'; +interface WhiteboardRoom { + id: string; + user_id: string; + name: string; + context_type: string; + is_default: boolean; + storage_path: string | null; + neo4j_node_id: string | null; + neo4j_db_name: string | null; + node_type: string | null; +} + +interface NavigationStoreWithAuth extends NavigationStore { + _accessToken: string | null; + _userId: string | null; + setAuthInfo: (token: string | null, userId: string | null) => void; +} + const initialState: NavigationContextState = { main: 'profile', base: 'profile', node: null, - history: { - nodes: [], - currentIndex: -1 - } + history: { nodes: [], currentIndex: -1 } }; function getDefaultBaseForMain(main: MainContext): BaseContext { @@ -38,402 +51,288 @@ function validateContextTransition( updates: Partial ): NavigationContextState { const newState = { ...current, ...updates }; - - // Validate main context if (updates.main) { newState.base = getDefaultBaseForMain(updates.main); } - - // Validate base context if (updates.base) { - // Ensure base context matches main context - const isValid = newState.main === 'profile' + const isValid = newState.main === 'profile' ? isProfileContext(updates.base) : isInstituteContext(updates.base); - if (!isValid) { newState.base = getDefaultBaseForMain(newState.main); } } - return newState; } -export interface NavigationActions { - // Context Navigation - setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise; - setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise; - setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise; - - // Node Navigation - navigate: (nodeId: string, dbName: string) => Promise; - navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise; - - // History Navigation - goBack: () => void; - goForward: () => void; - - // Utility Methods - refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise; -} +export const useNavigationStore = create((set, get) => { + const pgFetch = async ( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + table: string, + options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} + ): Promise => { + const token = get()._accessToken; + if (!token) throw new Error('pgFetch: no access token'); + const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`; + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY, + 'Content-Type': 'application/json', + }; + if (options.prefer) headers['Prefer'] = options.prefer; + if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json'; + const res = await fetch(url, { + method, + headers, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`PostgREST ${res.status}: ${err}`); + } + if (res.status === 204) return null; + return res.json() as Promise; + }; -export interface NavigationState { - context: { - main: NodeContext; - base: NodeContext; - extended?: string; - node: NavigationNode; - history: { - nodes: NavigationNode[]; - currentIndex: number; + const getOrCreateDefaultRoom = async (contextType: string): Promise => { + const userId = get()._userId; + if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID'); + + const rooms = await pgFetch('GET', 'whiteboard_rooms', { + query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`, + }); + + if (rooms && rooms.length > 0) { + const room = rooms[0]; + return { + id: room.id, + node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`, + label: room.name, + type: 'workspace', + }; + } + + const storagePath = `${userId}/workspaces/${contextType}_default.json`; + const room = await pgFetch('POST', 'whiteboard_rooms', { + body: { + user_id: userId, + name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`, + context_type: contextType, + is_default: true, + storage_path: storagePath, + }, + prefer: 'return=representation', + single: true, + }); + + if (!room) throw new Error('Failed to create default whiteboard room'); + logger.debug('navigation-context', 'βœ… Created default whiteboard room', { contextType, roomId: room.id }); + + return { + id: room.id, + node_storage_path: room.storage_path || storagePath, + label: room.name, + type: 'workspace', }; }; - // ... rest of the state interface ... -} -export const useNavigationStore = create((set, get) => ({ - context: initialState, - isLoading: false, - error: null, + return { + _accessToken: null, + _userId: null, + setAuthInfo: (token: string | null, userId: string | null) => { + set({ _accessToken: token, _userId: userId }); + }, - switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => { - try { - // Check if we have the necessary database connections - if (contextSwitch.main === 'profile' && !userDbName) { - logger.error('navigation-context', '❌ User database connection not initialized'); - set({ - error: 'User database connection not initialized', - isLoading: false - }); - return; - } - if (contextSwitch.main === 'institute' && !workerDbName) { - logger.error('navigation-context', '❌ Worker database connection not initialized'); - set({ - error: 'Worker database connection not initialized', - isLoading: false - }); + context: initialState, + isLoading: false, + error: null, + + switchContext: async (contextSwitch: UnifiedContextSwitch, _userDbName: string | null = null, _workerDbName: string | null = null) => { + if (!get()._accessToken || !get()._userId) { + logger.warn('navigation-context', '⚠️ switchContext called without auth β€” skipping'); return; } + try { + set({ isLoading: true, error: null }); + const currentState = get().context; + let newState: NavigationContextState = { ...currentState, node: null }; - logger.debug('navigation-context', 'πŸ”„ Starting context switch', { - from: { - main: get().context.main, - base: get().context.base, - extended: contextSwitch.extended, - nodeId: get().context.node?.id - }, - to: { - main: contextSwitch.main, - base: contextSwitch.base, - extended: contextSwitch.extended - }, - skipBaseContextLoad: contextSwitch.skipBaseContextLoad - }); - - set({ isLoading: true, error: null }); - - const currentState = get().context; - - // Clear node state immediately - const clearedState: NavigationContextState = { - ...currentState, - node: null - }; - set({ - context: clearedState, - isLoading: true - }); - - let newState: NavigationContextState = { - ...currentState, - node: null - }; - - // Update main context if provided - if (contextSwitch.main) { - newState = validateContextTransition(newState, { main: contextSwitch.main }); - if (!contextSwitch.skipBaseContextLoad) { - newState.base = getDefaultBaseForMain(contextSwitch.main); + if (contextSwitch.main) { + newState = validateContextTransition(newState, { main: contextSwitch.main }); + if (!contextSwitch.skipBaseContextLoad) { + newState.base = getDefaultBaseForMain(contextSwitch.main); + } } - logger.debug('navigation-state', 'βœ… Main context updated', { - previous: currentState.main, - new: newState.main, - defaultBase: newState.base - }); - } - - // Update base context if provided - if (contextSwitch.base) { - newState = validateContextTransition(newState, { base: contextSwitch.base }); - logger.debug('navigation-state', 'βœ… Base context updated', { - previous: currentState.base, - new: newState.base - }); - } - - logger.debug('navigation-state', 'βœ… Context validation complete', { - validatedState: newState, - originalState: currentState - }); - - // Determine which context to use for the node - const targetContext = contextSwitch.base || - contextSwitch.extended || - (contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : - newState.base); - - // Get database name - const dbName = getContextDatabase(newState, userDbName, workerDbName); - - logger.debug('context-switch', 'πŸ” Fetching default node for context', { - targetContext, - dbName, - currentState: newState - }); - - // Get default node for the final context - const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName); - - if (!defaultNode) { - const errorMsg = `No default node found for context: ${targetContext}`; - logger.error('context-switch', '❌ Default node fetch failed', { targetContext }); - set({ - error: errorMsg, - isLoading: false - }); - return; - } - - logger.debug('context-switch', '✨ Default node fetched', { - nodeId: defaultNode.id, - node_storage_path: defaultNode.node_storage_path, - type: defaultNode.type - }); - - // Update history and state - const newHistory = addToHistory(currentState.history, defaultNode); - logger.debug('history-management', 'πŸ“š History updated', { - previousState: currentState.history, - newState: newHistory, - addedNode: defaultNode - }); - - set({ - context: { - ...newState, - node: defaultNode, - history: newHistory - }, - isLoading: false, - error: null - }); - - logger.debug('navigation-context', 'βœ… Context switch completed', { - finalState: { - main: newState.main, - base: newState.base, - nodeId: defaultNode.id + if (contextSwitch.base) { + newState = validateContextTransition(newState, { base: contextSwitch.base }); } - }); - } catch (error) { - logger.error('navigation-context', '❌ Failed to switch context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to switch context', - isLoading: false - }); - } - }, - goBack: () => { - const currentState = get().context; - if (currentState.history.currentIndex > 0) { - const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1); - const node = getCurrentHistoryNode(newHistory); - set({ - context: { - ...currentState, - node, - history: newHistory - } - }); - } - }, + const targetContext = contextSwitch.base || + contextSwitch.extended || + (contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base); - goForward: () => { - const currentState = get().context; - if (currentState.history.currentIndex < currentState.history.nodes.length - 1) { - const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1); - const node = getCurrentHistoryNode(newHistory); - set({ - context: { - ...currentState, - node, - history: newHistory - } - }); - } - }, + const defaultNode = await getOrCreateDefaultRoom(targetContext); + const newHistory = addToHistory(currentState.history, defaultNode); - setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => { - try { - // Use switchContext instead of direct implementation - await get().switchContext({ main }, userDbName, workerDbName); - } catch (error) { - logger.error('navigation', '❌ Failed to set main context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to set main context', - isLoading: false - }); - } - }, - - setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => { - try { - // Use switchContext instead of direct implementation - await get().switchContext({ base }, userDbName, workerDbName); - } catch (error) { - logger.error('navigation', '❌ Failed to set base context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to set base context', - isLoading: false - }); - } - }, - - setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => { - try { - // Use switchContext instead of direct implementation - await get().switchContext({ extended }, userDbName, workerDbName); - } catch (error) { - logger.error('navigation', '❌ Failed to set extended context:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to set extended context', - isLoading: false - }); - } - }, - - navigate: async (nodeId: string, dbName: string) => { - try { - set({ isLoading: true, error: null }); - - // Check if we already have this node in history - const currentState = get().context; - const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); - - // If node exists in history, just navigate to it - if (existingNodeIndex !== -1) { - logger.debug('navigation', 'πŸ“ Navigating to existing node in history', { - nodeId, - historyIndex: existingNodeIndex, - currentIndex: currentState.history.currentIndex - }); - - const newHistory = navigateHistory(currentState.history, existingNodeIndex); - const node = getCurrentHistoryNode(newHistory); - set({ - context: { - ...currentState, - node, - history: newHistory - }, + context: { ...newState, node: defaultNode, history: newHistory }, isLoading: false, - error: null + error: null, }); + logger.debug('navigation-context', 'βœ… Context switch complete', { + main: newState.main, base: newState.base, nodeId: defaultNode.id, + }); + } catch (error) { + logger.error('navigation-context', '❌ switchContext failed', error); + set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false }); + } + }, + + goBack: () => { + const currentState = get().context; + if (currentState.history.currentIndex > 0) { + const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1); + set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } }); + } + }, + + goForward: () => { + const currentState = get().context; + if (currentState.history.currentIndex < currentState.history.nodes.length - 1) { + const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1); + set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } }); + } + }, + + setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => { + await get().switchContext({ main }, userDbName, workerDbName); + }, + + setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => { + await get().switchContext({ base }, userDbName, workerDbName); + }, + + setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => { + await get().switchContext({ extended }, userDbName, workerDbName); + }, + + navigate: async (nodeId: string, _dbName: string) => { + try { + set({ isLoading: true, error: null }); + if (!get()._accessToken) { set({ isLoading: false }); return; } + + const currentState = get().context; + const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); + if (existingIndex !== -1) { + const newHistory = navigateHistory(currentState.history, existingIndex); + set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false }); + return; + } + + const rooms = await pgFetch('GET', 'whiteboard_rooms', { + query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`, + }); + if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`); + + const room = rooms[0]; + const node: NavigationNode = { + id: room.id, + node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`, + label: room.name, + type: 'workspace', + }; + const newHistory = addToHistory(currentState.history, node); + set({ context: { ...currentState, node, history: newHistory }, isLoading: false }); + } catch (error) { + logger.error('navigation', '❌ navigate failed', error); + set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false }); + } + }, + + navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => { + if (!isValidNodeType(node.type)) { + logger.warn('navigation', `⚠️ navigateToNode called with non-graph type: ${node.type} β€” navigating anyway`); + } + await get().navigate(node.id, userDbName || ''); + }, + + refreshNavigationState: async (_userDbName: string | null, _workerDbName: string | null) => { + try { + set({ isLoading: true, error: null }); + const currentNode = get().context.node; + if (currentNode && get()._accessToken) { + const rooms = await pgFetch('GET', 'whiteboard_rooms', { + query: `id=eq.${currentNode.id}`, + }); + if (rooms && rooms.length > 0) { + const room = rooms[0]; + set({ + context: { + ...get().context, + node: { + id: room.id, + node_storage_path: room.storage_path || currentNode.node_storage_path, + label: room.name, + type: 'workspace', + }, + }, + }); + } + } + set({ isLoading: false }); + } catch (error) { + logger.error('navigation', '❌ refreshNavigationState failed', error); + set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false }); + } + }, + + navigateToNeoNode: async (neoNode: NeoGraphNode) => { + const userId = get()._userId; + if (!userId || !get()._accessToken) { + logger.warn('navigation', '⚠️ navigateToNeoNode called without auth'); return; } - - // Fetch new node data - const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName); - if (!nodeData) { - throw new Error(`Node not found: ${nodeId}`); - } - - const node: NavigationNode = { - id: nodeId, - node_storage_path: nodeData.node_data.node_storage_path || '', - label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId, - type: nodeData.node_type - }; - - logger.debug('navigation', 'πŸ“ Adding new node to history', { - nodeId: node.id, - type: node.type, - node_storage_path: node.node_storage_path - }); - - // Add to history and update state - const newHistory = addToHistory(currentState.history, node); - set({ - context: { - ...currentState, - node, - history: newHistory - }, - isLoading: false, - error: null - }); - } catch (error) { - logger.error('navigation', '❌ Failed to navigate:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to navigate', - isLoading: false - }); - } - }, - - navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => { - try { - set({ isLoading: true, error: null }); - - if (!isValidNodeType(node.type)) { - throw new Error(`Invalid node type: ${node.type}`); - } - - const dbName = getContextDatabase(get().context, userDbName, workerDbName); - await get().navigate(node.id, dbName); - } catch (error) { - logger.error('navigation', '❌ Failed to navigate to node:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to navigate to node', - isLoading: false - }); - } - }, - - refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => { - try { - set({ isLoading: true, error: null }); - const currentState = get().context; - - if (currentState.node) { - const dbName = getContextDatabase(currentState, userDbName, workerDbName); - const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName); - if (nodeData) { - const node: NavigationNode = { - id: currentState.node.id, - node_storage_path: nodeData.node_data.node_storage_path || '', - label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id, - type: nodeData.node_type - }; - set({ - context: { - ...currentState, - node - } + try { + set({ isLoading: true, error: null }); + const existing = await pgFetch('GET', 'whiteboard_rooms', { + query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`, + }); + let room: WhiteboardRoom; + if (existing && existing.length > 0) { + room = existing[0]; + } else { + const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`; + const created = await pgFetch('POST', 'whiteboard_rooms', { + body: { + user_id: userId, + name: neoNode.label, + context_type: neoNode.node_type.toLowerCase(), + is_default: false, + storage_path: storagePath, + neo4j_node_id: neoNode.neo4j_node_id, + neo4j_db_name: neoNode.neo4j_db_name, + node_type: neoNode.node_type, + }, + prefer: 'return=representation', + single: true, }); + if (!created) throw new Error('Failed to create whiteboard room for node'); + room = created; } + const node: NavigationNode = { + id: room.id, + node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`, + label: room.name, + type: neoNode.node_type, + }; + const currentState = get().context; + const newHistory = addToHistory(currentState.history, node); + set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null }); + logger.debug('navigation', 'βœ… Navigated to Neo4j node', { neoNode }); + } catch (error) { + logger.error('navigation', '❌ navigateToNeoNode failed', error); + set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false }); } - - set({ isLoading: false }); - } catch (error) { - logger.error('navigation', '❌ Failed to refresh navigation state:', error); - set({ - error: error instanceof Error ? error.message : 'Failed to refresh navigation state', - isLoading: false - }); - } - } -})); \ No newline at end of file + }, + }; +}); diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts index c65137f..f3574bd 100644 --- a/src/stores/transcriptionStore.ts +++ b/src/stores/transcriptionStore.ts @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import { supabase } from '../supabaseClient'; export interface TranscriptionSegment { text: string; @@ -168,545 +167,577 @@ interface TranscriptionState { clearKeywordMatches: () => void; } -export const useTranscriptionStore = create((set, get) => ({ - isRecording: false, - isConnecting: false, - activeSession: null, - _accessToken: null, - _userId: null, - completedSegments: [], - serverWindow: [], - currentSegment: null, - pendingCanvasEvents: [], - timetableContext: null, - wordCount: 0, - elapsedSeconds: 0, - - // LLM config initialized from localStorage - llmConfig: loadLLMConfig(), - - // Summary state - summaryText: null, - isGeneratingSummary: false, - summaryError: null, - - // Export state - isExporting: false, - exportError: null, - - // Keyword state - keywordWatches: [], - keywordMatches: [], - - setTimetableContext: (context) => { - set({ timetableContext: context }); - }, - - setAuthInfo: (token: string | null, userId: string | null) => { - set({ _accessToken: token, _userId: userId }); - }, - - startSession: async (timetableTag?: TimetablePeriod) => { - set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); - - // Create session in Supabase - try { - const { _accessToken: token, _userId: userId } = get(); - if (!token || !userId) { - console.error('No authenticated user'); - return; - } - - const sessionData = { - user_id: userId, - title: timetableTag?.event_label || 'Untitled Session', - canvas_type: 'teaching-canvas', - timetable_period_id: timetableTag?.period_id || null, - timetable_event_type: timetableTag?.event_type || null, - timetable_event_label: timetableTag?.event_label || null, - auto_tagged: !!timetableTag, - }; - - const { data, error } = await supabase - .from('transcription_sessions') - .insert(sessionData) - .select() - .single(); - - if (error) { - console.error('Failed to create session:', error); - return; - } - - set({ activeSession: data }); - } catch (error) { - console.error('Error starting session:', error); +export const useTranscriptionStore = create((set, get) => { + // Direct PostgREST fetch β€” uses stored _accessToken, no GoTrueClient lock. + const pgFetch = async ( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + table: string, + options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} + ): Promise => { + const token = get()._accessToken; + if (!token) throw new Error('pgFetch: no access token'); + const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`; + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY, + 'Content-Type': 'application/json', + }; + if (options.prefer) headers['Prefer'] = options.prefer; + if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json'; + const res = await fetch(url, { + method, + headers, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`PostgREST ${res.status}: ${err}`); } - }, + if (res.status === 204) return null; + return res.json() as Promise; + }; - stopSession: async () => { - const { activeSession, currentSegment, completedSegments } = get(); + return { + isRecording: false, + isConnecting: false, + activeSession: null, + _accessToken: null, + _userId: null, + completedSegments: [], + serverWindow: [], + currentSegment: null, + pendingCanvasEvents: [], + timetableContext: null, + wordCount: 0, + elapsedSeconds: 0, - // The live segment (currentSegment) was never added to completedSegments β€” flush it now. - let newCompleted = [...completedSegments]; - if (currentSegment && currentSegment.text.trim()) { - const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5); - if (!alreadyIn) { - const idx = newCompleted.length; - newCompleted.push({ ...currentSegment, isFinal: true }); - if (activeSession) { - supabase.from('transcription_segments').insert({ - session_id: activeSession.id, - sequence_index: idx, - text: currentSegment.text, - start_seconds: currentSegment.start, - end_seconds: currentSegment.end, - is_final: true, - }).then(({ error }) => { if (error) console.error('Failed to save live segment on stop:', error); }); - } - } - } + // LLM config initialized from localStorage + llmConfig: loadLLMConfig(), - const finalWordCount = newCompleted.reduce( - (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, - 0 - ); + // Summary state + summaryText: null, + isGeneratingSummary: false, + summaryError: null, + + // Export state + isExporting: false, + exportError: null, + + // Keyword state + keywordWatches: [], + keywordMatches: [], + + setTimetableContext: (context) => { + set({ timetableContext: context }); + }, + + setAuthInfo: (token: string | null, userId: string | null) => { + set({ _accessToken: token, _userId: userId }); + }, + + startSession: async (timetableTag?: TimetablePeriod) => { + set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); - if (activeSession) { try { - await supabase - .from('transcription_sessions') - .update({ - ended_at: new Date().toISOString(), - word_count: finalWordCount, - segment_count: newCompleted.length, - }) - .eq('id', activeSession.id); - } catch (error) { - console.error('Failed to end session:', error); - } - } - - set({ - isRecording: false, - isConnecting: false, - activeSession: null, - completedSegments: newCompleted, - serverWindow: [], - currentSegment: null, - wordCount: finalWordCount, - }); - }, - - updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => { - const { completedSegments, activeSession } = get(); - - if (segments.length === 0) return; - - // The server marks every finalized segment with completed=true and the live - // one with completed=false. Rather than relying on window-scroll detection - // (which can miss segments when the server creates several at once), we - // directly merge every completed segment from this message into the store. - // This guarantees no gaps: any segment the server says is complete is captured - // immediately, regardless of how many were created since the last message. - const serverCompleted = isLastLive ? segments.slice(0, -1) : segments; - - let newCompleted = [...completedSegments]; - const toSave: Array<{ seg: ServerSegment; idx: number }> = []; - - for (const seg of serverCompleted) { - if (!seg.text.trim()) continue; - const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5); - if (existingIdx >= 0) { - // Server refined an existing segment β€” update text and end time in place. - newCompleted[existingIdx] = { - ...newCompleted[existingIdx], - text: seg.text, - end: seg.end, - }; - } else { - const newIdx = newCompleted.length; - newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end }); - toSave.push({ seg, idx: newIdx }); - } - } - - // Keep sorted by start time so display order is always correct. - newCompleted.sort((a, b) => a.start - b.start); - - // Persist and keyword-check only truly new segments. - if (toSave.length > 0) { - const elapsed = get().elapsedSeconds; - for (const { seg, idx } of toSave) { - if (activeSession) { - supabase.from('transcription_segments').insert({ - session_id: activeSession.id, - sequence_index: idx, - text: seg.text, - start_seconds: seg.start, - end_seconds: seg.end, - is_final: true, - }).then(({ error }) => { if (error) console.error('Failed to save segment:', error); }); + const { _userId: userId } = get(); + if (!userId) { + console.error('No authenticated user'); + return; } - get().checkSegmentForKeywords(seg.text, elapsed); + + const sessionData = { + user_id: userId, + title: timetableTag?.event_label || 'Untitled Session', + canvas_type: 'teaching-canvas', + timetable_period_id: timetableTag?.period_id || null, + timetable_event_type: timetableTag?.event_type || null, + timetable_event_label: timetableTag?.event_label || null, + auto_tagged: !!timetableTag, + }; + + const data = await pgFetch('POST', 'transcription_sessions', { + body: sessionData, + prefer: 'return=representation', + single: true, + }); + + if (!data) { + console.error('Failed to create session: no data returned'); + return; + } + + set({ activeSession: data }); + } catch (error) { + console.error('Error starting session:', error); } - } + }, - const lastSeg = segments[segments.length - 1]; - const newCurrentSegment: TranscriptionSegment | null = isLastLive - ? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end } - : null; + stopSession: async () => { + const { activeSession, currentSegment, completedSegments } = get(); - const newWordCount = newCompleted.reduce( - (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, - 0 - ); + // The live segment (currentSegment) was never added to completedSegments β€” flush it now. + let newCompleted = [...completedSegments]; + if (currentSegment && currentSegment.text.trim()) { + const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5); + if (!alreadyIn) { + const idx = newCompleted.length; + newCompleted.push({ ...currentSegment, isFinal: true }); + if (activeSession) { + pgFetch('POST', 'transcription_segments', { + body: { + session_id: activeSession.id, + sequence_index: idx, + text: currentSegment.text, + start_seconds: currentSegment.start, + end_seconds: currentSegment.end, + is_final: true, + }, + }).catch(err => console.error('Failed to save live segment on stop:', err)); + } + } + } - set({ - serverWindow: segments, - completedSegments: newCompleted, - currentSegment: newCurrentSegment, - wordCount: newWordCount, - }); - }, - - saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { - const { completedSegments, currentSegment, activeSession } = get(); - - if (isFinal) { - // Deduplicate by start time: if a segment with this start already exists, update it - // rather than appending. This prevents doubles when the stability timer fires and - // the segment later appears in the server's finalized list with a slightly extended end. - const existingIdx = completedSegments.findIndex( - (s) => Math.abs(s.start - metadata.start) < 0.5 + const finalWordCount = newCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 ); - let newCompleted: TranscriptionSegment[]; - let isNew: boolean; - if (existingIdx >= 0) { - newCompleted = completedSegments.map((s, i) => - i === existingIdx ? { text, isFinal: true, ...metadata } : s - ); - isNew = false; - } else { - newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }]; - isNew = true; + if (activeSession) { + try { + await pgFetch('PATCH', 'transcription_sessions', { + query: `id=eq.${activeSession.id}`, + body: { + ended_at: new Date().toISOString(), + word_count: finalWordCount, + segment_count: newCompleted.length, + }, + }); + } catch (error) { + console.error('Failed to end session:', error); + } } + set({ + isRecording: false, + isConnecting: false, + activeSession: null, + completedSegments: newCompleted, + serverWindow: [], + currentSegment: null, + wordCount: finalWordCount, + }); + }, + + updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => { + const { completedSegments, activeSession } = get(); + + if (segments.length === 0) return; + + // The server marks every finalized segment with completed=true and the live + // one with completed=false. Rather than relying on window-scroll detection + // (which can miss segments when the server creates several at once), we + // directly merge every completed segment from this message into the store. + // This guarantees no gaps: any segment the server says is complete is captured + // immediately, regardless of how many were created since the last message. + const serverCompleted = isLastLive ? segments.slice(0, -1) : segments; + + let newCompleted = [...completedSegments]; + const toSave: Array<{ seg: ServerSegment; idx: number }> = []; + + for (const seg of serverCompleted) { + if (!seg.text.trim()) continue; + const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5); + if (existingIdx >= 0) { + // Server refined an existing segment β€” update text and end time in place. + newCompleted[existingIdx] = { + ...newCompleted[existingIdx], + text: seg.text, + end: seg.end, + }; + } else { + const newIdx = newCompleted.length; + newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end }); + toSave.push({ seg, idx: newIdx }); + } + } + + // Keep sorted by start time so display order is always correct. + newCompleted.sort((a, b) => a.start - b.start); + + // Persist and keyword-check only truly new segments. + if (toSave.length > 0) { + const elapsed = get().elapsedSeconds; + for (const { seg, idx } of toSave) { + if (activeSession) { + pgFetch('POST', 'transcription_segments', { + body: { + session_id: activeSession.id, + sequence_index: idx, + text: seg.text, + start_seconds: seg.start, + end_seconds: seg.end, + is_final: true, + }, + }).catch(err => console.error('Failed to save segment:', err)); + } + get().checkSegmentForKeywords(seg.text, elapsed); + } + } + + const lastSeg = segments[segments.length - 1]; + const newCurrentSegment: TranscriptionSegment | null = isLastLive + ? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end } + : null; + const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); - set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); + set({ + serverWindow: segments, + completedSegments: newCompleted, + currentSegment: newCurrentSegment, + wordCount: newWordCount, + }); + }, - if (isNew && activeSession) { - try { - await supabase.from('transcription_segments').insert({ - session_id: activeSession.id, - sequence_index: newCompleted.length - 1, - text, - start_seconds: metadata.start, - end_seconds: metadata.end, - is_final: true, - }); - } catch (error) { - console.error('Failed to save segment:', error); + saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { + const { completedSegments, currentSegment, activeSession } = get(); + + if (isFinal) { + // Deduplicate by start time: if a segment with this start already exists, update it + // rather than appending. This prevents doubles when the stability timer fires and + // the segment later appears in the server's finalized list with a slightly extended end. + const existingIdx = completedSegments.findIndex( + (s) => Math.abs(s.start - metadata.start) < 0.5 + ); + + let newCompleted: TranscriptionSegment[]; + let isNew: boolean; + if (existingIdx >= 0) { + newCompleted = completedSegments.map((s, i) => + i === existingIdx ? { text, isFinal: true, ...metadata } : s + ); + isNew = false; + } else { + newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }]; + isNew = true; } - } - } else { - // In-progress segment. If the start time jumped to a new position, the previous - // live segment is done β€” auto-commit it before switching. - if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) { - const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }]; - const autoWordCount = autoCompleted.reduce( + + const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); - set({ completedSegments: autoCompleted, wordCount: autoWordCount }); - if (activeSession) { - supabase.from('transcription_segments').insert({ - session_id: activeSession.id, - sequence_index: autoCompleted.length - 1, - text: currentSegment.text, - start_seconds: currentSegment.start, - end_seconds: currentSegment.end, - is_final: true, - }).then(({ error }) => { if (error) console.error('Failed to save auto-committed segment:', error); }); - } - } - set({ currentSegment: { text, isFinal: false, ...metadata } }); - } - }, - resetSession: () => { - set({ - isRecording: false, - isConnecting: false, - completedSegments: [], - serverWindow: [], - currentSegment: null, - wordCount: 0, - elapsedSeconds: 0, - activeSession: null, - pendingCanvasEvents: [], - timetableContext: null, - keywordMatches: [], - }); - }, + set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); - tickElapsed: () => { - set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 })); - }, - - addCanvasEvent: (event) => { - set((state) => ({ - pendingCanvasEvents: [...state.pendingCanvasEvents, event], - })); - }, - - flushCanvasEvents: async () => { - const { pendingCanvasEvents, activeSession } = get(); - - if (pendingCanvasEvents.length === 0) return; - - const eventsToFlush = [...pendingCanvasEvents]; - - try { - for (const event of eventsToFlush) { - await supabase.from('canvas_events').insert({ - session_id: activeSession?.id || null, - user_id: get()._userId || '', - timestamp: new Date().toISOString(), - session_elapsed_seconds: event.sessionElapsedSeconds || null, - event_type: event.eventType, - event_payload: event.payload || {}, - canvas_snapshot_url: event.snapshotUrl || null, - tldraw_page_id: event.pageId || null, - tldraw_shape_ids: event.shapeIds || null, - }); - } - - set({ pendingCanvasEvents: [] }); - } catch (error) { - console.error('Failed to flush canvas events:', error); - } - }, - - loadSessions: async (): Promise => { - try { - const { _userId: userId } = get(); - if (!userId) return []; - - const { data, error } = await supabase - .from('transcription_sessions') - .select('*') - .eq('user_id', userId) - .order('started_at', { ascending: false }) - .limit(50); - - if (error) { - console.error('Failed to load sessions:', error); - return []; - } - - return data || []; - } catch (error) { - console.error('Error loading sessions:', error); - return []; - } - }, - - // LLM config actions - persist to localStorage only - setLLMConfig: (partialConfig: Partial) => { - const current = get().llmConfig; - const updated = { ...current, ...partialConfig }; - saveLLMConfig(updated); - set({ llmConfig: updated }); - }, - - getLLMConfig: (): LLMConfig => { - return get().llmConfig; - }, - - // Summary actions - setSummaryText: (text: string | null) => { - set({ summaryText: text }); - }, - - setIsGeneratingSummary: (generating: boolean) => { - set({ isGeneratingSummary: generating }); - }, - - setSummaryError: (error: string | null) => { - set({ summaryError: error }); - }, - - // Export actions - exportSession: async (sessionId: string, format: ExportFormat) => { - set({ isExporting: true, exportError: null }); - - try { - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ format }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`); - } - - // Get filename from Content-Disposition header or use default - const disposition = response.headers.get('Content-Disposition'); - let filename = `transcription-export.${format}`; - if (disposition) { - const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/); - if (match && match[1]) { - filename = match[1].replace(/["'\\]/g, ''); - } - } - - // Trigger browser download - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - } catch (error) { - console.error('Failed to export session:', error); - set({ exportError: error instanceof Error ? error.message : 'Failed to export session' }); - } finally { - set({ isExporting: false }); - } - }, - - setExportError: (error: string | null) => { - set({ exportError: error }); - }, - - loadKeywordWatches: async () => { - try { - const { _accessToken: token } = get(); - if (!token) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - if (!response.ok) return; - const watches = await response.json(); - set({ keywordWatches: watches }); - } catch (error) { - console.error('Failed to load keyword watches:', error); - } - }, - - addKeywordWatch: async (keyword: string) => { - try { - const { _accessToken: token, _userId: userId } = get(); - if (!token || !userId) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ - user_id: userId, - keyword: keyword.trim(), - match_type: 'contains', - action: 'alert', - }), - }); - if (!response.ok) return; - const newWatch = await response.json(); - set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] })); - } catch (error) { - console.error('Failed to add keyword watch:', error); - } - }, - - deleteKeywordWatch: async (watchId: string) => { - try { - const { _accessToken: token } = get(); - if (!token) return; - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` }, - }); - set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); - } catch (error) { - console.error('Failed to delete keyword watch:', error); - } - }, - - checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => { - const { keywordWatches, activeSession } = get(); - if (keywordWatches.length === 0) return; - - const lowerText = text.toLowerCase(); - const matches: KeywordMatch[] = []; - - for (const watch of keywordWatches) { - if (!watch.is_active) continue; - const lowerKeyword = watch.keyword.toLowerCase(); - const matched = - watch.match_type === 'exact' - ? lowerText === lowerKeyword - : watch.match_type === 'starts_with' - ? lowerText.startsWith(lowerKeyword) - : lowerText.includes(lowerKeyword); - - if (matched) { - matches.push({ - keyword: watch.keyword, - watch_id: watch.id, - segment_text: text, - elapsed_seconds: elapsedSeconds, - matched_at: new Date().toISOString(), - }); - - if (activeSession) { + if (isNew && activeSession) { try { - const { _accessToken: _kwToken } = get(); - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - await fetch(`${apiBaseUrl}/transcribe/keywords/events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(_kwToken ? { 'Authorization': `Bearer ${_kwToken}` } : {}), - }, - body: JSON.stringify({ + await pgFetch('POST', 'transcription_segments', { + body: { session_id: activeSession.id, - keyword_watch_id: watch.id, - keyword_text: watch.keyword, - matched_in_text: text, - session_elapsed_seconds: elapsedSeconds, - }), + sequence_index: newCompleted.length - 1, + text, + start_seconds: metadata.start, + end_seconds: metadata.end, + is_final: true, + }, }); } catch (error) { - console.error('Failed to log keyword event:', error); + console.error('Failed to save segment:', error); + } + } + } else { + // In-progress segment. If the start time jumped to a new position, the previous + // live segment is done β€” auto-commit it before switching. + if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) { + const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }]; + const autoWordCount = autoCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 + ); + set({ completedSegments: autoCompleted, wordCount: autoWordCount }); + if (activeSession) { + pgFetch('POST', 'transcription_segments', { + body: { + session_id: activeSession.id, + sequence_index: autoCompleted.length - 1, + text: currentSegment.text, + start_seconds: currentSegment.start, + end_seconds: currentSegment.end, + is_final: true, + }, + }).catch(err => console.error('Failed to save auto-committed segment:', err)); + } + } + set({ currentSegment: { text, isFinal: false, ...metadata } }); + } + }, + + resetSession: () => { + set({ + isRecording: false, + isConnecting: false, + completedSegments: [], + serverWindow: [], + currentSegment: null, + wordCount: 0, + elapsedSeconds: 0, + activeSession: null, + pendingCanvasEvents: [], + timetableContext: null, + keywordMatches: [], + }); + }, + + tickElapsed: () => { + set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 })); + }, + + addCanvasEvent: (event) => { + set((state) => ({ + pendingCanvasEvents: [...state.pendingCanvasEvents, event], + })); + }, + + flushCanvasEvents: async () => { + const { pendingCanvasEvents, activeSession } = get(); + + if (pendingCanvasEvents.length === 0) return; + + const eventsToFlush = [...pendingCanvasEvents]; + + try { + for (const event of eventsToFlush) { + await pgFetch('POST', 'canvas_events', { + body: { + session_id: activeSession?.id || null, + user_id: get()._userId || '', + timestamp: new Date().toISOString(), + session_elapsed_seconds: event.sessionElapsedSeconds || null, + event_type: event.eventType, + event_payload: event.payload || {}, + canvas_snapshot_url: event.snapshotUrl || null, + tldraw_page_id: event.pageId || null, + tldraw_shape_ids: event.shapeIds || null, + }, + }); + } + + set({ pendingCanvasEvents: [] }); + } catch (error) { + console.error('Failed to flush canvas events:', error); + } + }, + + loadSessions: async (): Promise => { + try { + const { _userId: userId } = get(); + if (!userId) return []; + + const data = await pgFetch('GET', 'transcription_sessions', { + query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`, + }); + + return data || []; + } catch (error) { + console.error('Error loading sessions:', error); + return []; + } + }, + + // LLM config actions - persist to localStorage only + setLLMConfig: (partialConfig: Partial) => { + const current = get().llmConfig; + const updated = { ...current, ...partialConfig }; + saveLLMConfig(updated); + set({ llmConfig: updated }); + }, + + getLLMConfig: (): LLMConfig => { + return get().llmConfig; + }, + + // Summary actions + setSummaryText: (text: string | null) => { + set({ summaryText: text }); + }, + + setIsGeneratingSummary: (generating: boolean) => { + set({ isGeneratingSummary: generating }); + }, + + setSummaryError: (error: string | null) => { + set({ summaryError: error }); + }, + + // Export actions + exportSession: async (sessionId: string, format: ExportFormat) => { + set({ isExporting: true, exportError: null }); + + try { + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ format }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`); + } + + // Get filename from Content-Disposition header or use default + const disposition = response.headers.get('Content-Disposition'); + let filename = `transcription-export.${format}`; + if (disposition) { + const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/); + if (match && match[1]) { + filename = match[1].replace(/["'\\]/g, ''); + } + } + + // Trigger browser download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error('Failed to export session:', error); + set({ exportError: error instanceof Error ? error.message : 'Failed to export session' }); + } finally { + set({ isExporting: false }); + } + }, + + setExportError: (error: string | null) => { + set({ exportError: error }); + }, + + loadKeywordWatches: async () => { + try { + const { _accessToken: token } = get(); + if (!token) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!response.ok) return; + const watches = await response.json(); + set({ keywordWatches: watches }); + } catch (error) { + console.error('Failed to load keyword watches:', error); + } + }, + + addKeywordWatch: async (keyword: string) => { + try { + const { _accessToken: token, _userId: userId } = get(); + if (!token || !userId) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + user_id: userId, + keyword: keyword.trim(), + match_type: 'contains', + action: 'alert', + }), + }); + if (!response.ok) return; + const newWatch = await response.json(); + set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] })); + } catch (error) { + console.error('Failed to add keyword watch:', error); + } + }, + + deleteKeywordWatch: async (watchId: string) => { + try { + const { _accessToken: token } = get(); + if (!token) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` }, + }); + set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); + } catch (error) { + console.error('Failed to delete keyword watch:', error); + } + }, + + checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => { + const { keywordWatches, activeSession } = get(); + if (keywordWatches.length === 0) return; + + const lowerText = text.toLowerCase(); + const matches: KeywordMatch[] = []; + + for (const watch of keywordWatches) { + if (!watch.is_active) continue; + const lowerKeyword = watch.keyword.toLowerCase(); + const matched = + watch.match_type === 'exact' + ? lowerText === lowerKeyword + : watch.match_type === 'starts_with' + ? lowerText.startsWith(lowerKeyword) + : lowerText.includes(lowerKeyword); + + if (matched) { + matches.push({ + keyword: watch.keyword, + watch_id: watch.id, + segment_text: text, + elapsed_seconds: elapsedSeconds, + matched_at: new Date().toISOString(), + }); + + if (activeSession) { + try { + const { _accessToken: kwToken } = get(); + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + await fetch(`${apiBaseUrl}/transcribe/keywords/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}), + }, + body: JSON.stringify({ + session_id: activeSession.id, + keyword_watch_id: watch.id, + keyword_text: watch.keyword, + matched_in_text: text, + session_elapsed_seconds: elapsedSeconds, + }), + }); + } catch (error) { + console.error('Failed to log keyword event:', error); + } } } } - } - if (matches.length > 0) { - set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] })); - } - }, + if (matches.length > 0) { + set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] })); + } + }, - clearKeywordMatches: () => { - set({ keywordMatches: [] }); - }, -})); + clearKeywordMatches: () => { + set({ keywordMatches: [] }); + }, + }; +}); diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 7fbe598..e792be0 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -227,6 +227,13 @@ export interface UnifiedContextSwitch { } // Navigation Actions Interface +export interface NeoGraphNode { + neo4j_node_id: string; + neo4j_db_name: string; + node_type: string; + label: string; +} + export interface NavigationActions { // Unified Context Switch switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise; @@ -239,6 +246,7 @@ export interface NavigationActions { // Node Navigation navigate: (nodeId: string, dbName: string) => Promise; navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise; + navigateToNeoNode: (neoNode: NeoGraphNode) => Promise; // History Navigation goBack: () => void; diff --git a/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx b/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx index edfe71f..32093f4 100644 --- a/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx +++ b/src/utils/tldraw/cc-base/cc-transcription/transcriptionService.tsx @@ -7,6 +7,14 @@ export interface TranscriptionConfig { useVad?: boolean; } +export interface ServerSegment { + text: string; + start: number; + end: number; +} + +type ServerSegmentsCallback = (segments: ServerSegment[], isLastLive: boolean) => void; + export class TranscriptionService { private socket: WebSocket | null = null; private stream: MediaStream | null = null; @@ -14,27 +22,29 @@ export class TranscriptionService { private mediaStreamSource: MediaStreamAudioSourceNode | null = null; private workletNode: AudioWorkletNode | null = null; private selectedDeviceId: string = ''; - private finalizedSegmentCount: number = 0; - private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null; + private intentionalStop: boolean = false; + private onServerSegments: ServerSegmentsCallback | null = null; + private onDisconnect: (() => void) | null = null; constructor(deviceId: string = '') { this.selectedDeviceId = deviceId; } - setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) { - this.onTranscriptionUpdate = callback; + setServerSegmentsCallback(callback: ServerSegmentsCallback) { + this.onServerSegments = callback; + } + + setDisconnectCallback(callback: () => void) { + this.onDisconnect = callback; } async startTranscription(config: TranscriptionConfig = {}) { console.log('πŸŽ™οΈ Starting transcription service...'); + this.intentionalStop = false; try { logger.info('transcription-service', 'πŸ”Š Requesting microphone access...'); - // Call getUserMedia directly β€” this triggers the browser permission prompt. - // The old code called enumerateDevices() first to find a device ID, but - // without microphone permission deviceId is always (empty string, falsy), - // causing an early return that never prompted the user for permission. const audioConstraints: MediaTrackConstraints = this.selectedDeviceId ? { deviceId: { exact: this.selectedDeviceId } } : { echoCancellation: true, noiseSuppression: true }; @@ -60,13 +70,13 @@ export class TranscriptionService { clearTimeout(connectionTimeout); logger.info('transcription-service', 'βœ… WebSocket connected'); - // Send initial configuration β€” audio capture starts only after SERVER_READY. ws.send(JSON.stringify({ uid: uuid, language: config.language || 'en', task: config.task || 'transcribe', - model: config.modelSize || 'base', + model: config.modelSize || 'large-v3', use_vad: config.useVad ?? true, + max_connection_time: 7200, // server default is 600 s β€” set to 2 h })); }; @@ -76,17 +86,18 @@ export class TranscriptionService { ws.onclose = () => { logger.info('transcription-service', 'πŸ”Œ WebSocket closed'); + const wasIntentional = this.intentionalStop; this.cleanup(); + if (!wasIntentional && this.onDisconnect) { + this.onDisconnect(); + } }; ws.onmessage = (event) => { const data = JSON.parse(event.data); - if (data.uid !== uuid) { - return; - } + if (data.uid !== uuid) return; if (data.message === 'SERVER_READY') { - // Server is ready β€” now safe to start streaming audio. logger.info('transcription-service', '🟒 Server ready, starting audio capture'); this.setupAudioProcessing(); return; @@ -94,37 +105,29 @@ export class TranscriptionService { if (data.status === 'WAIT') { logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`); + this.intentionalStop = true; this.cleanup(); return; } if (data.message === 'DISCONNECT') { logger.info('transcription-service', 'πŸ”• Server requested disconnection'); + this.intentionalStop = true; this.cleanup(); return; } - if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) { - const segments = data.segments; - const lastIdx = segments.length - 1; - - // Only emit segments we have not finalized yet β€” avoids re-processing the - // full array on every message (which caused the stuck last segment bug). - for (let i = this.finalizedSegmentCount; i < lastIdx; i++) { - const seg = segments[i]; - this.onTranscriptionUpdate(seg.text, true, { - start: parseFloat(seg.start), - end: parseFloat(seg.end), - }); - this.finalizedSegmentCount = i + 1; - } - - // Always update the live (last) segment - const lastSeg = segments[lastIdx]; - this.onTranscriptionUpdate(lastSeg.text, lastSeg.completed ?? false, { - start: parseFloat(lastSeg.start), - end: parseFloat(lastSeg.end), - }); + // Pass the full segment window directly to the store β€” the store owns + // all boundary and archival decisions, matching the WhisperLive reference + // frontend which simply re-renders the server's authoritative segment list. + if (this.onServerSegments && data.segments && data.segments.length > 0) { + const segs: ServerSegment[] = data.segments.map((s: any) => ({ + text: String(s.text ?? ''), + start: parseFloat(s.start ?? 0), + end: parseFloat(s.end ?? 0), + })); + const isLastLive = !(data.segments[data.segments.length - 1]?.completed); + this.onServerSegments(segs, isLastLive); } }; } catch (error) { @@ -134,26 +137,18 @@ export class TranscriptionService { } private async setupAudioProcessing() { - if (!this.stream || !this.socket) { - return; - } + if (!this.stream || !this.socket) return; try { - // Request 16 kHz from the browser β€” it resamples natively so we send - // the correct rate to the server without any JS resampling overhead. this.audioContext = new AudioContext({ sampleRate: 16000 }); - await this.audioContext.audioWorklet.addModule('/audioWorklet.js'); this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream); this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor'); - // The worklet accumulates 4096 samples (256 ms at 16 kHz) before posting, - // matching the reference frontend chunk size and eliminating the tiny-frame - // flood that was overwhelming the server during silence. this.workletNode.port.onmessage = (event) => { if (this.socket?.readyState === WebSocket.OPEN) { - this.socket.send(event.data); // event.data is a transferred ArrayBuffer + this.socket.send(event.data); } }; @@ -165,7 +160,7 @@ export class TranscriptionService { } stopTranscription() { - // Signal the server cleanly so it can finalise the last segment. + this.intentionalStop = true; if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send('END_OF_AUDIO'); } @@ -173,27 +168,22 @@ export class TranscriptionService { } private cleanup() { - this.finalizedSegmentCount = 0; if (this.workletNode) { this.workletNode.disconnect(); this.workletNode = null; } - if (this.mediaStreamSource) { this.mediaStreamSource.disconnect(); this.mediaStreamSource = null; } - if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } - if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); this.stream = null; } - if (this.socket) { this.socket.close(); this.socket = null; diff --git a/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx index 623d452..945a872 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx @@ -1,15 +1,49 @@ import React, { useState, useEffect } from 'react'; -import Close from '@mui/icons-material/Close'; import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore'; const PROVIDERS = [ { value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, - { value: 'ollama', label: 'Ollama' }, + { value: 'ollama', label: 'Ollama (local)' }, { value: 'openrouter', label: 'OpenRouter' }, - { value: 'google', label: 'Google' }, + { value: 'google', label: 'Google Gemini' }, ] as const; +const WHISPER_MODELS = [ + { value: 'tiny', label: 'Tiny (fastest, least accurate)' }, + { value: 'tiny.en', label: 'Tiny English' }, + { value: 'base', label: 'Base' }, + { value: 'base.en', label: 'Base English' }, + { value: 'small', label: 'Small' }, + { value: 'small.en', label: 'Small English' }, + { value: 'medium', label: 'Medium' }, + { value: 'medium.en', label: 'Medium English' }, + { value: 'large-v2', label: 'Large v2' }, + { value: 'large-v3', label: 'Large v3 (best accuracy)' }, +]; + +const fieldStyle: React.CSSProperties = { + width: '100%', + padding: '7px 10px', + border: '1px solid var(--color-divider)', + borderRadius: '6px', + backgroundColor: 'var(--color-muted)', + color: 'var(--color-text)', + fontSize: '13px', + outline: 'none', + boxSizing: 'border-box', +}; + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '12px', + fontWeight: 600, + color: 'var(--color-text-2)', + marginBottom: '4px', + textTransform: 'uppercase', + letterSpacing: '0.05em', +}; + const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => { const { llmConfig, setLLMConfig } = useTranscriptionStore(); const [form, setForm] = useState(llmConfig); @@ -25,97 +59,196 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is const handleSave = () => { setLLMConfig(form); setSaved(true); - setTimeout(() => setSaved(false), 2000); + setTimeout(() => { + setSaved(false); + onClose(); + }, 1000); }; if (!isOpen) return null; return ( -
+
{ if (e.target === e.currentTarget) onClose(); }} + > {/* Backdrop */} -
+
{/* Modal panel */} -
+
e.stopPropagation()} + > {/* Header */} -
-

- LLM Provider Settings -

+
+ + Settings +
{/* Content */} -
- {/* Provider dropdown */} +
+ + {/* ── Transcription section ── */}
- +
+ Transcription +
+ +
+ Larger models are more accurate but slower to load. Server has large-v3 downloaded. +
- {/* Model name */} + {/* ── LLM section ── */}
- - setForm({ ...form, model: e.target.value })} - placeholder="e.g. gpt-4o, claude-sonnet-4-20250514" - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
+
+ AI Summary Provider +
- {/* API Key */} -
- - setForm({ ...form, apiKey: e.target.value })} - placeholder="sk-..." - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
+
+
+ + +
- {/* Note */} -

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

+
+ + setForm({ ...form, model: e.target.value })} + placeholder={ + form.provider === 'ollama' ? 'e.g. gemma4:e4b, llama3.2' : + form.provider === 'anthropic' ? 'e.g. claude-sonnet-4-6' : + form.provider === 'google' ? 'e.g. gemini-2.0-flash' : + 'e.g. gpt-4o, gpt-4o-mini' + } + style={fieldStyle} + /> +
+ + {form.provider === 'ollama' && ( +
+ + setForm({ ...form, baseUrl: e.target.value })} + placeholder="https://ollama.kevlarai.com" + style={fieldStyle} + /> +
+ )} + +
+ + setForm({ ...form, apiKey: e.target.value })} + placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : 'sk-...'} + style={fieldStyle} + /> +
+
+ +
+ API keys are stored in your browser only. +
+
{/* Save button */}
From 83adcce9516058aa0008c123fa8c1d391ded4bbb Mon Sep 17 00:00:00 2001 From: kcar Date: Tue, 26 May 2026 01:25:29 +0100 Subject: [PATCH 7/7] feat(phase-b): school/timetable wizards, graph nav panel, UI updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New components: - CCGraphNavPanel: Supabase-driven navigation tree (school/timetable/calendar/classes sections), role-aware setup buttons, lazy child loading, academic/generic calendar toggle - SchoolCalendarWizard: 3-step admin-only school setup (details β†’ term dates β†’ daily periods) - TeacherTimetableWizard: period grid with existing slot pre-loading, edit-mode title Updated: - CCNodeSnapshotPanel: saves via Supabase storage path + accessToken - BasePanel: nav panel tab wired to CCGraphNavPanel - CCFilesPanelEnhanced: auth context fixes - CCDocumentIntelligence suite: accessToken threading, Supabase storage integration Co-Authored-By: Claude Sonnet 4.6 --- .../CCDocumentIntelligence/CCBundleViewer.tsx | 15 +- .../CCDoclingViewer.tsx | 14 +- .../CCDocumentIntelligence.tsx | 13 +- .../CCEnhancedFilePanel.tsx | 8 +- .../CCFileDetailPanel.tsx | 13 +- .../components/shared/BasePanel.tsx | 6 +- .../shared/CCFilesPanelEnhanced.tsx | 9 +- .../shared/navigation/CCGraphNavPanel.tsx | 586 ++++++++++++++++++ .../shared/navigation/CCNodeSnapshotPanel.tsx | 8 +- .../navigation/SchoolCalendarWizard.tsx | 316 ++++++++++ .../navigation/TeacherTimetableWizard.tsx | 244 ++++++++ 11 files changed, 1196 insertions(+), 36 deletions(-) create mode 100644 src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx create mode 100644 src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx create mode 100644 src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx 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/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/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/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 }} + /> + + ))} + + ))} + +
+
+
+ )} +
+ + + + + +
+ ); +}