Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
BIN
View File
Binary file not shown.
+265
View File
@@ -0,0 +1,265 @@
import { User, AuthChangeEvent, Session } from '@supabase/supabase-js';
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;
email?: string;
user_type: string;
username: string;
display_name: string;
user_db_name: string;
school_db_name: string;
created_at?: string;
updated_at?: string;
}
export interface CCUserMetadata {
username?: string;
user_type?: string;
display_name?: string;
email?: string;
name?: string;
preferred_username?: string;
[key: string]: string | undefined;
}
export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
// Extract username from various possible sources
const username = metadata.username ||
metadata.preferred_username ||
metadata.email?.split('@')[0] ||
user.email?.split('@')[0] ||
'user';
// Extract display name from various possible sources
const displayName = metadata.display_name ||
metadata.name ||
metadata.preferred_username ||
username;
// Default to student if no user type specified
const userType = metadata.user_type || 'student';
const userDbName = DatabaseNameService.getUserPrivateDB(
userType,
username
);
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
return {
id: user.id,
email: user.email,
user_type: userType,
username: username,
display_name: displayName,
user_db_name: userDbName,
school_db_name: schoolDbName,
created_at: user.created_at,
updated_at: user.updated_at,
};
}
export type UserRole =
| 'email_teacher'
| 'email_student'
| 'cc_admin'
| 'cc_developer'
| 'super_admin';
// Login response
export interface LoginResponse {
user: CCUser | null;
accessToken: string | null;
userRole: string;
message: string | null;
}
// Session response
export interface SessionResponse {
user: CCUser | null;
accessToken: string | null;
message: string | null;
}
// Registration response
export interface RegistrationResponse extends LoginResponse {
user: CCUser;
accessToken: string | null;
userRole: UserRole;
message: string | null;
}
export interface EmailCredentials {
email: string;
password: string;
role: 'email_teacher' | 'email_student';
}
export type AuthCredentials = EmailCredentials;
export const getTldrawPreferences = (user: CCUser): TLUserPreferences => {
return {
id: user.id,
colorScheme: 'system',
};
};
class AuthService {
private static instance: AuthService;
private constructor() {}
onAuthStateChange(
callback: (event: AuthChangeEvent, session: Session | null) => void
) {
return supabase.auth.onAuthStateChange((event, session) => {
logger.info('auth-service', '🔄 Auth state changed', {
event,
hasSession: !!session,
userId: session?.user?.id,
eventType: event,
});
// Ensure we clear storage on signout
if (event === 'SIGNED_OUT') {
storageService.clearAll();
}
callback(event, session);
});
}
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
async getCurrentSession(): Promise<SessionResponse> {
try {
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) {
throw error;
}
if (!session) {
return { user: null, accessToken: null, message: 'No active session' };
}
return {
user: convertToCCUser(
session.user,
session.user.user_metadata as CCUserMetadata
),
accessToken: session.access_token,
message: 'Session retrieved',
};
} catch (error) {
logger.error('auth-service', 'Failed to get current session:', error);
throw error;
}
}
async getCurrentUser(): Promise<CCUser | null> {
try {
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null;
}
return convertToCCUser(user, user.user_metadata as CCUserMetadata);
} catch (error) {
logger.error('auth-service', 'Failed to get current user:', error);
return null;
}
}
async login({
email,
password,
role,
}: EmailCredentials): Promise<LoginResponse> {
try {
logger.info('auth-service', '🔄 Attempting login', {
email,
role,
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
logger.error('auth-service', '❌ Supabase auth error', {
error: error.message,
status: error.status,
});
throw error;
}
if (!data.session) {
logger.error('auth-service', '❌ No session after login');
throw new Error('No session after login');
}
const ccUser = convertToCCUser(
data.user,
data.user.user_metadata as CCUserMetadata
);
// Store auth session in storage
storageService.set(StorageKeys.USER_ROLE, ccUser.user_type);
storageService.set(StorageKeys.USER, ccUser);
storageService.set(StorageKeys.SUPABASE_TOKEN, data.session.access_token);
logger.info('auth-service', '✅ Login successful', {
userId: ccUser.id,
role: ccUser.user_type,
username: ccUser.username,
});
return {
user: ccUser,
accessToken: data.session.access_token,
userRole: ccUser.user_type,
message: 'Login successful',
};
} catch (error) {
logger.error('auth-service', '❌ Login failed:', error);
throw error;
}
}
async logout(): Promise<void> {
try {
logger.debug('auth-service', '🔄 Attempting logout');
const { error } = await supabase.auth.signOut({ scope: 'local' });
if (error) {
logger.error('auth-service', '❌ Logout failed:', error);
throw error;
}
// Clear all stored data
storageService.clearAll();
// Force a refresh of the auth state
await supabase.auth.refreshSession();
logger.debug('auth-service', '✅ Logout successful');
} catch (error) {
logger.error('auth-service', '❌ Logout failed:', error);
throw error;
}
}
}
export const authService = AuthService.getInstance();
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
import { TLUserPreferences, TLUser } from '@tldraw/tldraw';
import { CCUser } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
// Type-safe storage keys
export enum StorageKeys {
USER = 'user',
USER_ROLE = 'user_role',
SUPABASE_TOKEN = 'supabase_token',
MS_TOKEN = 'msAccessToken',
NEO4J_USER_DB = 'neo4jUserDbName',
NEO4J_WORKER_DB = 'neo4jWorkerDbName',
USER_NODES = 'userNodes',
CALENDAR_DATA = 'calendarData',
IS_NEW_REGISTRATION = 'isNewRegistration',
TLDRAW_PREFERENCES = 'tldrawUserPreferences',
TLDRAW_FILE_PATH = 'tldrawUserFilePath',
LOCAL_SNAPSHOT = 'localSnapshot',
NODE_FILE_PATH = 'nodeFilePath',
ONENOTE_NOTEBOOK = 'oneNoteNotebook',
PRESENTATION_MODE = 'presentationMode',
TLDRAW_USER = 'tldrawUser'
}
interface StorageValueTypes {
[StorageKeys.USER]: CCUser;
[StorageKeys.USER_ROLE]: string;
[StorageKeys.SUPABASE_TOKEN]: string;
[StorageKeys.MS_TOKEN]: string;
[StorageKeys.NEO4J_USER_DB]: string;
[StorageKeys.NEO4J_WORKER_DB]: string;
[StorageKeys.USER_NODES]: any[];
[StorageKeys.CALENDAR_DATA]: any;
[StorageKeys.IS_NEW_REGISTRATION]: boolean;
[StorageKeys.TLDRAW_PREFERENCES]: TLUserPreferences;
[StorageKeys.TLDRAW_FILE_PATH]: string;
[StorageKeys.LOCAL_SNAPSHOT]: any;
[StorageKeys.NODE_FILE_PATH]: string;
[StorageKeys.ONENOTE_NOTEBOOK]: any;
[StorageKeys.PRESENTATION_MODE]: boolean;
[StorageKeys.TLDRAW_USER]: TLUser;
}
type StorageKey = keyof StorageValueTypes;
class StorageService {
private static instance: StorageService;
private constructor() {}
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
get<K extends StorageKey>(key: K): StorageValueTypes[K] | null {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
logger.error('storage-service', `Error retrieving ${key}:`, error);
return null;
}
}
set<K extends StorageKey>(key: K, value: StorageValueTypes[K]): void {
try {
const serializedValue = JSON.stringify(value);
localStorage.setItem(key, serializedValue);
logger.debug('storage-service', `Stored ${key} in localStorage`);
} catch (error) {
logger.error('storage-service', `Error storing ${key}:`, error);
}
}
remove(key: StorageKey): void {
try {
localStorage.removeItem(key);
logger.debug('storage-service', `Removed ${key} from localStorage`);
} catch (error) {
logger.error('storage-service', `Error removing ${key}:`, error);
}
}
clearAll(): void {
try {
// Only clear app-specific storage, not Supabase's internal keys
Object.values(StorageKeys).forEach(key => {
localStorage.removeItem(key);
});
logger.debug('storage-service', 'Cleared all app items from localStorage');
} catch (error) {
logger.error('storage-service', 'Error clearing storage:', error);
}
}
// Helper method to update state and storage together
setStateAndStorage<K extends StorageKey>(
setter: React.Dispatch<React.SetStateAction<StorageValueTypes[K]>>,
key: K,
value: StorageValueTypes[K]
): void {
setter(value);
this.set(key, value);
}
}
export const storageService = StorageService.getInstance();
@@ -0,0 +1,107 @@
import { supabase } from '../../../supabaseClient';
import axios from '../../../axiosConfig';
export interface StandardizedOneNoteDetails {
id: string;
displayName: string;
createdDateTime: string;
lastModifiedDateTime: string;
links: {
oneNoteClientUrl: string;
oneNoteWebUrl: string;
};
}
export async function updateUserOneNoteDetails(userId: string, oneNoteDetails: StandardizedOneNoteDetails) {
const { error } = await supabase
.from('profiles')
.update({
one_note_details: oneNoteDetails,
updated_at: new Date().toISOString()
})
.eq('id', userId);
if (error) {
console.error('Error updating OneNote details:', error);
throw error;
}
}
export async function getOneNoteNotebooks(msAccessToken: string) {
try {
const response = await axios.get(`/msgraph/onenote/get-onenote-notebooks`, {
headers: {
'Authorization': `Bearer ${msAccessToken}`,
'Content-Type': 'application/json'
},
});
return response.data;
} catch (error) {
console.error('Error getting notebooks:', error);
throw error;
}
}
export async function createOneNoteNotebook(msAccessToken: string, uid: string): Promise<StandardizedOneNoteDetails> {
if (!msAccessToken) {
throw new Error('Microsoft token not found');
}
try {
const notebooks = await getOneNoteNotebooks(msAccessToken);
const existingNotebook = notebooks.value.find((notebook: any) =>
notebook.displayName === 'Classroom Copilot'
);
if (existingNotebook) {
const standardizedNotebook = standardizeNotebookDetails(existingNotebook);
await updateUserOneNoteDetails(uid, standardizedNotebook);
return standardizedNotebook;
}
const response = await axios.post(
`/msgraph/onenote/create-onenote-notebook?notebook_name=${encodeURIComponent('Classroom Copilot')}`,
{},
{
headers: {
'Authorization': `Bearer ${msAccessToken}`,
'Content-Type': 'application/json'
},
}
);
if (response.status !== 200) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const standardizedNotebook = standardizeNotebookDetails(response.data);
await updateUserOneNoteDetails(uid, standardizedNotebook);
return standardizedNotebook;
} catch (error) {
console.error('Error creating notebook:', error);
throw error;
}
}
export async function registerOneNoteUser(msAccessToken: string, uid: string) {
try {
return await createOneNoteNotebook(msAccessToken, uid);
} catch (error) {
console.error('Error registering Microsoft user:', error);
throw error;
}
}
function standardizeNotebookDetails(notebook: any): StandardizedOneNoteDetails {
const notebookData = notebook.data || notebook;
return {
id: notebookData.id || '',
displayName: notebookData.displayName || '',
createdDateTime: notebookData.createdDateTime || '',
lastModifiedDateTime: notebookData.lastModifiedDateTime || '',
links: {
oneNoteClientUrl: notebookData.links?.oneNoteClientUrl?.href || '',
oneNoteWebUrl: notebookData.links?.oneNoteWebUrl?.href || '',
},
};
}
+86
View File
@@ -0,0 +1,86 @@
import { TLUserPreferences } from '@tldraw/tldraw';
import { supabase } from '../../supabaseClient';
import { logger } from '../../debugConfig';
import { CCUser } from './authService';
export type UserProfile = CCUser;
export interface UserProfileUpdate extends Partial<UserProfile> {
id: string; // ID is always required for updates
}
export interface UserPreferences {
tldraw?: TLUserPreferences;
theme?: 'light' | 'dark' | 'system';
notifications?: boolean;
}
export async function createUserProfile(profile: UserProfile): Promise<UserProfile | null> {
try {
const { data, error } = await supabase
.from('profiles')
.insert([profile])
.select()
.single();
if (error) {
logger.error('supabase-profile-service', '❌ Failed to create user profile', {
userId: profile.id,
error
});
throw error;
}
return data;
} catch (error) {
logger.error('supabase-profile-service', '❌ Error in createUserProfile', error);
return null;
}
}
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
try {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (error) {
logger.error('supabase-profile-service', '❌ Failed to fetch user profile', {
userId,
error
});
throw error;
}
return data;
} catch (error) {
logger.error('supabase-profile-service', '❌ Error in getUserProfile', error);
return null;
}
}
export async function updateUserProfile(update: UserProfileUpdate): Promise<UserProfile | null> {
try {
const { data, error } = await supabase
.from('profiles')
.update(update)
.eq('id', update.id)
.select()
.single();
if (error) {
logger.error('supabase-profile-service', '❌ Failed to update user profile', {
userId: update.id,
error
});
throw error;
}
return data;
} catch (error) {
logger.error('supabase-profile-service', '❌ Error in updateUserProfile', error);
return null;
}
}
+118
View File
@@ -0,0 +1,118 @@
import { supabase } from '../../supabaseClient';
import { CCUser, convertToCCUser } from '../../services/auth/authService';
import { EmailCredentials } from '../../services/auth/authService';
import { formatEmailForDatabase } from '../graph/neoDBService';
import { RegistrationResponse } from '../../services/auth/authService';
import { neoRegistrationService } from '../graph/neoRegistrationService';
import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig';
const REGISTRATION_SERVICE = 'registration-service';
export class RegistrationService {
private static instance: RegistrationService;
private constructor() {}
static getInstance(): RegistrationService {
if (!RegistrationService.instance) {
RegistrationService.instance = new RegistrationService();
}
return RegistrationService.instance;
}
async register(credentials: EmailCredentials, displayName: string): Promise<RegistrationResponse> {
try {
logger.debug(REGISTRATION_SERVICE, '🔄 Starting registration', {
email: credentials.email,
role: credentials.role,
hasDisplayName: !!displayName
});
// Generate username from email (or use another method)
const username = formatEmailForDatabase(credentials.email);
// 1. First sign up the user in auth
const { data: authData, error: signUpError } = await supabase.auth.signUp({
email: credentials.email,
password: credentials.password,
options: {
data: {
user_type: credentials.role,
username: username,
display_name: displayName
}
}
});
if (signUpError) {
logger.error(REGISTRATION_SERVICE, '❌ Supabase signup error', { error: signUpError });
throw signUpError;
}
if (!authData.user) {
logger.error(REGISTRATION_SERVICE, '❌ No user data after registration');
throw new Error('No user data after registration');
}
const ccUser: CCUser = convertToCCUser(authData.user, authData.user.user_metadata);
// 2. Update the profile with the correct user type
const { error: updateError } = await supabase
.from('profiles')
.update({
user_type: credentials.role,
username: username,
display_name: displayName
})
.eq('id', authData.user.id)
.select()
.single();
if (updateError) {
logger.error(REGISTRATION_SERVICE, '❌ Failed to update profile', updateError);
throw updateError;
}
storageService.set(StorageKeys.IS_NEW_REGISTRATION, true);
// 3. Create Neo4j nodes
try {
const userNode = await neoRegistrationService.registerNeo4JUser(
ccUser,
username, // Pass username for database operations
credentials.role
);
logger.info(REGISTRATION_SERVICE, '✅ Registration successful with Neo4j setup', {
userId: ccUser.id,
hasUserNode: !!userNode
});
return {
user: ccUser,
accessToken: authData.session?.access_token || null,
userRole: credentials.role,
message: 'Registration successful'
};
} catch (neo4jError) {
logger.warn(REGISTRATION_SERVICE, '⚠️ Neo4j setup problem', {
userId: ccUser.id,
error: neo4jError
});
// Return success even if Neo4j setup is pending
return {
user: ccUser,
accessToken: authData.session?.access_token || null,
userRole: credentials.role,
message: 'Registration successful - Neo4j setup pending'
};
}
} catch (error) {
logger.error(REGISTRATION_SERVICE, '❌ Registration failed:', error);
throw error;
}
}
}
export const registrationService = RegistrationService.getInstance();
@@ -0,0 +1,45 @@
export async function uploadCurriculum(file: File, backendUrl: string) {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${backendUrl}/database/curriculum/upload-subject-curriculum`, {
method: 'POST',
body: formData,
});
if (response.status === 200) {
const result = await response.json();
console.log(result);
alert('Upload Successful!');
} else {
alert('Upload failed!');
}
} catch (error) {
console.error('Error uploading curriculum:', error);
alert('Upload failed!');
}
}
export async function uploadSubjectCurriculum(file: File, backendUrl: string) {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${backendUrl}/database/curriculum/upload-subject-curriculum`, {
method: 'POST',
body: formData,
});
if (response.status === 200) {
const result = await response.json();
console.log(result);
alert('Upload Successful!');
} else {
alert('Upload failed!');
}
} catch (error) {
console.error('Error uploading curriculum:', error);
alert('Upload failed!');
}
}
+58
View File
@@ -0,0 +1,58 @@
import { logger } from '../../debugConfig';
export class DatabaseNameService {
static readonly CC_USERS = 'cc.users';
static readonly CC_SCHOOLS = 'cc.institutes';
static getUserPrivateDB(userType: string, username: string): string {
const dbName = `${this.CC_USERS}.${userType}.${username}`;
logger.debug('database-name-service', '📥 Generating user private DB name', {
userType,
username,
dbName
});
return dbName;
}
static getSchoolPrivateDB(schoolId: string): string {
const dbName = `${this.CC_SCHOOLS}.${schoolId}`;
logger.debug('database-name-service', '📥 Generating school private DB name', {
schoolId,
dbName
});
return dbName;
}
static getDevelopmentSchoolDB(): string {
const dbName = `${this.CC_SCHOOLS}.development.default`;
logger.debug('database-name-service', '📥 Getting default school DB name', {
dbName
});
return dbName;
}
static getContextDatabase(context: string, userType: string, username: string): string {
logger.debug('database-name-service', '📥 Resolving context database', {
context,
userType,
username
});
// For school-related contexts, use the schools database
if (['school', 'department', 'class'].includes(context)) {
logger.debug('database-name-service', '✅ Using schools database for context', {
context,
dbName: this.CC_SCHOOLS
});
return this.CC_SCHOOLS;
}
// For user-specific contexts, use their private database
const userDb = this.getUserPrivateDB(userType, username);
logger.debug('database-name-service', '✅ Using user private database for context', {
context,
dbName: userDb
});
return userDb;
}
}
+159
View File
@@ -0,0 +1,159 @@
import { Editor, createShapeId, IndexKey } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import { getShapeType, isValidNodeType, CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { AllNodeShapes, NodeShapeType, ShapeUtils } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-shapes';
import { graphState } from '../../utils/tldraw/cc-base/cc-graph/graphStateUtil';
import { NodeResponse, ConnectedNodesResponse } from '../../types/api';
import { logger } from '../../debugConfig';
export class GraphNeoDBService {
static async fetchConnectedNodesAndEdges(
unique_id: string,
db_name: string,
editor: Editor
) {
try {
logger.debug('graph-service', '📤 Fetching connected nodes', {
unique_id,
db_name
});
const response = await axios.get<ConnectedNodesResponse>(
'/database/tools/get-connected-nodes-and-edges', {
params: {
unique_id,
db_name
}
}
);
if (response.data.status === "success") {
// Make sure editor is set in graphState
graphState.setEditor(editor);
await this.processConnectedNodesResponse(response.data);
return true;
}
throw new Error('Failed to fetch connected nodes');
} catch (error) {
logger.error('graph-service', '❌ Failed to fetch connected nodes', { error });
throw error;
}
}
private static async processConnectedNodesResponse(
data: ConnectedNodesResponse
) {
try {
// Log the incoming data
logger.debug('graph-service', '📥 Processing nodes response', {
mainNode: data.main_node,
connectedNodesCount: data.connected_nodes?.length,
relationshipsCount: data.relationships?.length
});
// Create a batch of nodes to process
const nodesToProcess: NodeResponse['node_data'][] = [];
// Add connected nodes first
if (data.connected_nodes) {
data.connected_nodes.forEach(connectedNode => {
if (isValidNodeType(connectedNode.type)) {
// Convert the simplified node structure to node_data format
const nodeData = {
unique_id: connectedNode.id,
tldraw_snapshot: connectedNode.tldraw_snapshot,
name: connectedNode.label,
__primarylabel__: connectedNode.type as keyof CCNodeTypes,
created: new Date().toISOString(),
merged: new Date().toISOString(),
};
nodesToProcess.push(nodeData);
}
});
}
// Add main node last (if it exists) to ensure it's processed after connected nodes
if (data.main_node) {
nodesToProcess.push(data.main_node.node_data);
}
// Process all nodes in batch
for (const nodeData of nodesToProcess) {
await this.createOrUpdateNode(nodeData);
logger.debug('graph-service', '📝 Processed node', {
nodeId: nodeData.unique_id,
nodeType: nodeData.__primarylabel__
});
}
// After all nodes are processed, arrange them in grid
graphState.arrangeNodesInGrid();
logger.debug('graph-service', '✅ Processed nodes batch', {
processedCount: nodesToProcess.length,
totalNodes: graphState.getAllNodes().length,
nodesInState: Array.from(graphState.nodeData.keys())
});
} catch (error) {
logger.error('graph-service', '❌ Failed to process connected nodes response', { error });
throw error;
}
}
private static async createOrUpdateNode(
nodeData: NodeResponse['node_data']
) {
const uniqueId = nodeData.unique_id;
const nodeType = nodeData.__primarylabel__;
if (!isValidNodeType(nodeType)) {
logger.warn('graph-service', '⚠️ Unknown node type', { data: nodeData });
return;
}
const shapeType = getShapeType(nodeType) as NodeShapeType;
// Get the shape util for this node type
const shapeUtil = ShapeUtils[shapeType];
if (!shapeUtil) {
logger.warn('graph-service', '⚠️ No shape util found for type', { type: shapeType });
return;
}
// Get default props from the shape util's prototype
const defaultProps = shapeUtil.prototype.getDefaultProps();
// Create the shape with proper typing based on the node type
const shape = {
id: createShapeId(uniqueId),
type: shapeType,
x: 0,
y: 0,
rotation: 0,
index: 'a1' as IndexKey,
parentId: createShapeId('page:page'),
isLocked: false,
opacity: 1,
meta: {},
props: {
...defaultProps,
...nodeData,
__primarylabel__: nodeData.__primarylabel__,
unique_id: nodeData.unique_id,
tldraw_snapshot: nodeData.path as string || '',
}
};
// Add to graphState
graphState.addNode(shape as AllNodeShapes);
logger.debug('graph-service', '📝 Node processed', {
uniqueId,
nodeType,
shapeId: shape.id,
shapeType: shape.type
});
}
}
+118
View File
@@ -0,0 +1,118 @@
import { CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../../debugConfig';
export interface BaseNodeData {
unique_id: string;
path: string;
__primarylabel__: string;
[key: string]: unknown;
}
export interface CalendarNodeData extends BaseNodeData {
__primarylabel__: 'Calendar' | 'CalendarYear' | 'CalendarMonth' | 'CalendarWeek' | 'CalendarDay';
date?: string;
}
export interface WorkerNodeData extends BaseNodeData {
__primarylabel__: 'School' | 'Department' | 'Teacher' | 'UserTeacherTimetable' | 'Class' | 'TimetableLesson';
name?: string;
teacher_code?: string;
teacher_name_formal?: string;
class_code?: string;
department_code?: string;
school_name?: string;
}
export type NodeType = keyof CCNodeTypes | 'User' | 'Calendar' | 'CalendarYear' | 'CalendarMonth' | 'CalendarWeek' | 'CalendarDay' | 'Teacher' | 'UserTeacherTimetable' | 'Student' | 'Class' | 'TimetableLesson';
export function formatEmailForDatabase(email: string): string {
// Convert to lowercase and replace special characters
const sanitized = email.toLowerCase()
.replace('@', 'at')
.replace(/\./g, 'dot')
.replace(/_/g, 'underscore')
.replace(/-/g, 'dash');
// Add prefix and ensure no consecutive dashes
return `${sanitized}`;
}
export function generateNodeTitle(nodeData: BaseNodeData): string {
try {
const calendarData = nodeData as CalendarNodeData;
const workerData = nodeData as WorkerNodeData;
switch (nodeData.__primarylabel__ as NodeType) {
// Calendar nodes
case 'Calendar':
return 'Calendar';
case 'CalendarYear':
if (!calendarData.date) return 'Unknown Year';
return `Year ${new Date(calendarData.date).getFullYear()}`;
case 'CalendarMonth':
if (!calendarData.date) return 'Unknown Month';
return new Date(calendarData.date).toLocaleString('default', { month: 'long' });
case 'CalendarWeek':
if (!calendarData.date) return 'Unknown Week';
return `Week ${new Date(calendarData.date).getDate()}`;
case 'CalendarDay':
if (!calendarData.date) return 'Unknown Day';
return new Date(calendarData.date).toLocaleDateString();
// Worker/School nodes
case 'School':
return workerData.school_name || 'School';
case 'Department':
return workerData.department_code || 'Department';
case 'Teacher':
return workerData.teacher_name_formal || workerData.teacher_code || 'Teacher';
case 'UserTeacherTimetable':
return 'Timetable';
case 'Class':
return workerData.class_code || 'Class';
case 'TimetableLesson':
return 'Lesson';
default:
logger.warn('neo4j-service', `⚠️ Unknown node type for title generation: ${nodeData.__primarylabel__}`);
return 'Unknown Node';
}
} catch (error) {
logger.error('neo4j-service', '❌ Failed to generate node title', { error, nodeData });
return 'Error: Invalid Node Data';
}
}
export function getMonthFromWeek(weekDate: string): string {
// Get the month that contains the most days of this week
const weekStart = new Date(weekDate);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
// If week spans two months, use the month that contains more days of the week
if (weekStart.getMonth() !== weekEnd.getMonth()) {
const daysInFirstMonth = new Date(weekStart.getFullYear(), weekStart.getMonth() + 1, 0).getDate() - weekStart.getDate() + 1;
const daysInSecondMonth = 7 - daysInFirstMonth;
return daysInFirstMonth >= daysInSecondMonth ?
weekStart.toLocaleString('default', { month: 'long' }) :
weekEnd.toLocaleString('default', { month: 'long' });
}
return weekStart.toLocaleString('default', { month: 'long' });
}
export function getDatabaseName(path: string, defaultSchoolUuid = 'kevlarai'): string {
// If the path starts with /node_filesystem/users/, it's in a user database
if (path.startsWith('/node_filesystem/users/')) {
const parts = path.split('/');
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
return parts[3];
}
// For school/worker nodes, extract from the path or use default
if (path.includes('/schools/')) {
return `cc.institutes.${defaultSchoolUuid}`;
}
// Default to user database if we can't determine
return path.split('/')[3];
}
@@ -0,0 +1,155 @@
import { supabase } from '../../supabaseClient';
import { CCUser } from '../auth/authService';
import { CCSchoolNodeProps, CCUserNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { storageService, StorageKeys } from '../auth/localStorageService';
import axiosInstance from '../../axiosConfig';
import { logger } from '../../debugConfig';
// Dev configuration - only hardcoded value we need
const DEV_SCHOOL_UUID = 'kevlarai';
class NeoRegistrationService {
private static instance: NeoRegistrationService;
private constructor() {}
static getInstance(): NeoRegistrationService {
if (!NeoRegistrationService.instance) {
NeoRegistrationService.instance = new NeoRegistrationService();
}
return NeoRegistrationService.instance;
}
async registerNeo4JUser(
user: CCUser,
username: string,
role: string
): Promise<CCUserNodeProps> {
try {
// For teachers and students, fetch school node first
let schoolNode = null;
if (role.includes('teacher') || role.includes('student')) {
schoolNode = await this.fetchSchoolNode(DEV_SCHOOL_UUID);
if (!schoolNode) {
throw new Error('Failed to fetch required school node');
}
}
// Create FormData with proper headers
const formData = new FormData();
// Required fields
formData.append('user_id', user.id);
formData.append('user_type', role);
formData.append('user_name', username);
formData.append('user_email', user.email || '');
// Add school data if we have a school node
if (schoolNode) {
formData.append('school_uuid', schoolNode.school_uuid);
formData.append('school_name', schoolNode.school_name);
formData.append('school_website', schoolNode.school_website);
formData.append('school_tldraw_snapshot', schoolNode.tldraw_snapshot);
// Add worker data based on role
const workerData = role.includes('teacher') ? {
teacher_code: username,
teacher_name_formal: username,
teacher_email: user.email,
} : {
student_code: username,
student_name_formal: username,
student_email: user.email,
};
formData.append('worker_data', JSON.stringify(workerData));
}
// Debug log the form data
logger.debug('neo4j-service', '🔄 Sending form data', {
userId: user.id,
userType: role,
userName: username,
userEmail: user.email,
schoolNode: schoolNode ? {
uuid: schoolNode.school_uuid,
name: schoolNode.school_name
} : null
});
const response = await axiosInstance.post('/database/entity/create-user', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data.status !== 'success') {
throw new Error(`Failed to create user: ${JSON.stringify(response.data)}`);
}
const userNode = response.data.data.user_node;
const workerNode = response.data.data.worker_node;
// Store calendar data if needed
if (response.data.data.calendar_nodes) {
logger.debug('neo4j-service', '🔄 Storing calendar data', {
calendarNodes: response.data.data.calendar_nodes
});
storageService.set(StorageKeys.CALENDAR_DATA, response.data.data.calendar_nodes);
}
// Update user node with worker data
userNode.worker_node_data = JSON.stringify(workerNode);
await this.updateUserNeo4jDetails(user.id, userNode);
logger.info('neo4j-service', '✅ Neo4j user registration successful', {
userId: user.id,
nodeId: userNode.unique_id,
hasCalendar: !!response.data.data.calendar_nodes
});
return userNode;
} catch (error) {
logger.error('neo4j-service', '❌ Neo4j user registration failed', error);
throw error;
}
}
async updateUserNeo4jDetails(userId: string, userNode: CCUserNodeProps) {
const { error } = await supabase
.from('profiles')
.update({
metadata: {
...userNode
},
updated_at: new Date().toISOString()
})
.eq('id', userId);
if (error) {
logger.error('neo4j-service', '❌ Failed to update Neo4j details:', error);
throw error;
}
}
async fetchSchoolNode(schoolUuid: string): Promise<CCSchoolNodeProps> {
logger.debug('neo4j-service', '🔄 Fetching school node', { schoolUuid });
try {
const response = await axiosInstance.get(`/database/tools/get-school-node?school_uuid=${schoolUuid}`);
if (response.data?.status === 'success' && response.data.school_node) {
logger.info('neo4j-service', '✅ School node fetched successfully');
return response.data.school_node;
}
throw new Error('Failed to fetch school node: ' + JSON.stringify(response.data));
} catch (error) {
logger.error('neo4j-service', '❌ Failed to fetch school node:', error);
throw error;
}
}
}
export const neoRegistrationService = NeoRegistrationService.getInstance();
+78
View File
@@ -0,0 +1,78 @@
import { NavigationNode } from '../../types/navigation';
import { getShapeType, CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { logger } from '../../debugConfig';
import { NodeData } from '../../types/graph-shape';
export class NeoShapeService {
private static readonly DATE_TIME_FIELDS = [
'merged', 'created', 'start_date', 'end_date', 'start_time', 'end_time'
] as const;
private static processDateTimeFields(data: Record<string, unknown>): Record<string, unknown> {
const processed = { ...data };
for (const key of Object.keys(processed)) {
if (this.DATE_TIME_FIELDS.includes(key as typeof this.DATE_TIME_FIELDS[number]) &&
processed[key] &&
typeof processed[key] === 'object') {
processed[key] = processed[key].toString();
}
}
return processed;
}
static getShapeConfig(node: NavigationNode, nodeData: NodeData, centerX: number, centerY: number) {
try {
// Get the shape type based on the node type
const shapeType = getShapeType(node.type as keyof CCNodeTypes);
// Get theme colors based on the node type
const theme = getThemeFromLabel(node.type);
// Default dimensions
const width = 500;
const height = 350;
// Process the node data
const processedProps = {
...this.processDateTimeFields(nodeData),
title: nodeData.title || node.label,
w: width,
h: height,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
logger.debug('neo-shape-service', '📄 Created shape configuration', {
nodeId: node.id,
shapeType,
theme,
props: processedProps
});
return {
type: shapeType,
x: centerX - (width / 2),
y: centerY - (height / 2),
props: processedProps
};
} catch (error) {
logger.error('neo-shape-service', '❌ Failed to create shape configuration', {
nodeId: node.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import axiosInstance from '../../axiosConfig';
import { CCSchoolNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../../debugConfig';
import { AxiosError } from 'axios';
interface CreateSchoolResponse {
status: string;
message: string;
}
export class SchoolNeoDBService {
static async createSchools(
): Promise<CreateSchoolResponse> {
logger.warn('school-service', '📤 Creating schools using default config.yaml');
try {
const response = await axiosInstance.post(
'/database/entity/create-schools',
{},
{
headers: {
'Content-Type': 'application/json'
}
}
);
if (response.data.status === 'success' || response.data.status === 'Accepted') {
logger.info('school-service', '✅ Schools successfully');
return {
status: 'success',
message: 'Schools created successfully'
};
}
throw new Error(response.data.message || 'Creation failed');
} catch (err: unknown) {
const error = err as AxiosError;
logger.error('school-service', '❌ Failed to create school', {
error: error.message,
details: error.response?.data
});
throw error;
}
}
static async getSchoolNode(schoolDbName: string): Promise<CCSchoolNodeProps | null> {
logger.debug('school-service', '🔄 Fetching school node', { schoolDbName });
try {
const response = await axiosInstance.get(`/database/tools/get-default-node/school?db_name=${schoolDbName}`);
if (response.data?.status === 'success' && response.data.node) {
logger.info('school-service', '✅ School node fetched successfully');
return response.data.node;
}
logger.warn('school-service', '⚠️ No school node found');
return null;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 404) {
logger.warn('school-service', '⚠️ School node not found (404)', { schoolDbName });
return null;
}
logger.error('school-service', '❌ Failed to fetch school node:', error);
throw error;
}
}
}
+256
View File
@@ -0,0 +1,256 @@
import axios from '../../axiosConfig';
import { CCTeacherNodeProps, CCUserNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../../debugConfig';
import { AxiosError } from 'axios';
interface UploadTimetableResponse {
status: string;
message: string;
}
interface UploadResult {
success: boolean;
message: string;
}
export interface TeacherTimetableEvent {
id: string;
title: string;
start: string;
end: string;
extendedProps: {
subjectClass: string;
color: string;
periodCode: string;
tldraw_snapshot?: string;
};
}
export class TimetableNeoDBService {
static async uploadWorkerTimetable(
file: File,
userNode: CCUserNodeProps,
workerNode: CCTeacherNodeProps
): Promise<UploadTimetableResponse> {
logger.debug('timetable-service', '📤 Uploading timetable', {
fileName: file.name,
schoolDbName: workerNode.school_db_name,
userDbName: workerNode.user_db_name,
teacherCode: workerNode.teacher_code
});
const formData = new FormData();
formData.append('file', file);
formData.append('user_node', JSON.stringify({
unique_id: userNode.unique_id,
user_id: userNode.user_id,
user_type: userNode.user_type,
user_name: userNode.user_name,
user_email: userNode.user_email,
tldraw_snapshot: userNode.tldraw_snapshot,
worker_node_data: userNode.worker_node_data
}));
formData.append('worker_node', JSON.stringify({
unique_id: workerNode.unique_id,
teacher_code: workerNode.teacher_code,
teacher_name_formal: workerNode.teacher_name_formal,
teacher_email: workerNode.teacher_email,
tldraw_snapshot: workerNode.tldraw_snapshot,
worker_db_name: workerNode.school_db_name,
user_db_name: workerNode.user_db_name
}));
try {
const response = await axios.post(
'/database/timetables/upload-worker-timetable',
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
if (response.data.status === 'success' || response.data.status === 'Accepted') {
logger.info('timetable-service', '✅ Timetable upload successful');
return {
status: 'success',
message: 'Timetable uploaded successfully'
};
}
throw new Error(response.data.message || 'Upload failed');
} catch (err: unknown) {
const error = err as AxiosError;
logger.error('timetable-service', '❌ Failed to upload timetable', {
error: error.message,
details: error.response?.data
});
throw error;
}
}
static async fetchTeacherTimetableEvents(
unique_id: string,
school_db_name: string
): Promise<TeacherTimetableEvent[]> {
try {
logger.debug('timetable-service', '📤 Fetching timetable events', {
unique_id,
school_db_name
});
const response = await axios.get('/calendar/get_teacher_timetable_events', {
params: {
unique_id,
school_db_name
}
});
logger.debug('timetable-service', '📥 Received response', {
status: response.status,
data: response.data
});
if (response.data.status === "success") {
return response.data.events;
}
throw new Error(response.data.message || 'Failed to fetch events');
} catch (error) {
if (error instanceof AxiosError) {
logger.error('timetable-service', '❌ Failed to fetch timetable events', {
status: error.response?.status,
data: error.response?.data,
message: error.message
});
} else {
logger.error('timetable-service', '❌ Failed to fetch timetable events', { error });
}
throw error;
}
}
static lightenColor(color: string, amount: number): string {
color = color.replace(/^#/, '');
const num = parseInt(color, 16);
const r = Math.min(255, (num >> 16) + amount);
const g = Math.min(255, ((num >> 8) & 0x00FF) + amount);
const b = Math.min(255, (num & 0x0000FF) + amount);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
static getContrastColor(hexColor: string): string {
hexColor = hexColor.replace(/^#/, '');
const r = parseInt(hexColor.slice(0, 2), 16);
const g = parseInt(hexColor.slice(2, 4), 16);
const b = parseInt(hexColor.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.7 ? '#000000' : '#FFFFFF';
}
static getEventRange(events: TeacherTimetableEvent[]) {
if (events.length === 0) {
return { start: null, end: null };
}
let start = new Date(events[0].start);
let end = new Date(events[0].end);
events.forEach(event => {
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
if (eventStart < start) {
start = eventStart;
}
if (eventEnd > end) {
end = eventEnd;
}
});
start.setDate(1);
end.setMonth(end.getMonth() + 1, 0);
return { start, end };
}
static getSubjectClassColor(subjectClass: string): string {
let hash = 0;
for (let i = 0; i < subjectClass.length; i++) {
hash = subjectClass.charCodeAt(i) + ((hash << 5) - hash);
}
return `hsl(${hash % 360}, 70%, 50%)`;
}
static async handleTimetableUpload(
file: File | undefined,
userNode: CCUserNodeProps | undefined,
workerNode: CCTeacherNodeProps | undefined
): Promise<UploadResult> {
if (!file) {
return {
success: false,
message: 'No file selected'
};
}
if (!file.name.endsWith('.xlsx')) {
return {
success: false,
message: 'Please upload an Excel (.xlsx) file'
};
}
if (!userNode) {
return {
success: false,
message: 'User information not found. Please ensure you are logged in as a user.'
};
}
if (!workerNode) {
return {
success: false,
message: 'Teacher information not found. Please ensure you are logged in as a teacher.'
};
}
// Validate worker node has required fields
const requiredWorkerFields = ['unique_id', 'teacher_code', 'teacher_name_formal', 'teacher_email', 'worker_db_name', 'path'];
const requiredUserFields = ['unique_id', 'user_id', 'user_type', 'user_name', 'user_email', 'path', 'worker_node_data'];
const missingWorkerFields = requiredWorkerFields.filter(field => !(field in workerNode));
const missingUserFields = requiredUserFields.filter(field => !(field in userNode));
if (missingWorkerFields.length > 0) {
logger.error('timetable-service', '❌ Missing required teacher fields:', { missingWorkerFields });
return {
success: false,
message: `Missing required teacher information: ${missingWorkerFields.join(', ')}`
};
}
if (missingUserFields.length > 0) {
logger.error('timetable-service', '❌ Missing required user fields:', { missingUserFields });
return {
success: false,
message: `Missing required user information: ${missingUserFields.join(', ')}`
};
}
try {
const result = await this.uploadWorkerTimetable(file, userNode, workerNode);
return {
success: true,
message: result.message
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to upload timetable';
logger.error('timetable-service', '❌ Timetable upload failed:', error);
return {
success: false,
message: errorMessage
};
}
}
}
+390
View File
@@ -0,0 +1,390 @@
import axiosInstance from '../../axiosConfig';
import { formatEmailForDatabase } from './neoDBService';
import { CCUserNodeProps, CCTeacherNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { NavigationNode, NodeContext } from '../../types/navigation';
import { TLBinding, TLShapeId } from '@tldraw/tldraw';
import { logger } from '../../debugConfig';
import { useNavigationStore } from '../../stores/navigationStore';
import { DatabaseNameService } from './databaseNameService';
// Dev configuration - only hardcoded value we need
const DEV_SCHOOL_UUID = 'kevlarai';
interface ShapeState {
parentId: TLShapeId | null;
isPageChild: boolean | null;
hasChildren: boolean | null;
bindings: TLBinding[] | null;
}
interface NodeResponse {
status: string;
nodes: {
userNode: CCUserNodeProps;
calendarNode: CCCalendarNodeProps;
teacherNode: CCTeacherNodeProps;
timetableNode: CCUserTeacherTimetableNodeProps;
};
}
interface NodeDataResponse {
__primarylabel__: string;
unique_id: string;
tldraw_snapshot: string;
created: string;
merged: string;
state: ShapeState | null;
defaultComponent: boolean | null;
user_name?: string;
user_email?: string;
user_type?: string;
user_id?: string;
worker_node_data?: string;
[key: string]: string | number | boolean | null | ShapeState | undefined;
}
interface DefaultNodeResponse {
status: string;
node: {
id: string;
tldraw_snapshot: string;
type: string;
label: string;
data: NodeDataResponse;
};
}
export interface ProcessedUserNodes {
privateUserNode: CCUserNodeProps;
connectedNodes: {
calendar?: CCCalendarNodeProps;
teacher?: CCTeacherNodeProps;
timetable?: CCUserTeacherTimetableNodeProps;
};
}
export interface CalendarStructureResponse {
status: string;
data: {
currentDay: string;
days: Record<string, {
id: string;
date: string;
title: string;
}>;
weeks: Record<string, {
id: string;
title: string;
days: { id: string }[];
startDate: string;
endDate: string;
}>;
months: Record<string, {
id: string;
title: string;
days: { id: string }[];
weeks: { id: string }[];
year: string;
month: string;
}>;
years: {
id: string;
title: string;
months: { id: string }[];
year: string;
}[];
};
}
export interface WorkerStructureResponse {
status: string;
data: {
timetables: Record<string, Array<{
id: string;
title: string;
type: string;
startTime: string;
endTime: string;
}>>;
classes: Record<string, Array<{
id: string;
title: string;
type: string;
}>>;
lessons: Record<string, Array<{
id: string;
title: string;
type: string;
}>>;
journals: Record<string, Array<{
id: string;
title: string;
}>>;
planners: Record<string, Array<{
id: string;
title: string;
}>>;
};
}
export class UserNeoDBService {
static async fetchUserNodesData(
email: string,
userDbName?: string,
workerDbName?: string
): Promise<ProcessedUserNodes | null> {
try {
if (!userDbName) {
logger.error('neo4j-service', '❌ Attempted to fetch nodes without database name');
return null;
}
const formattedEmail = formatEmailForDatabase(email);
const uniqueId = `User_${formattedEmail}`;
logger.debug('neo4j-service', '🔄 Fetching user nodes data', {
email,
formattedEmail,
userDbName,
workerDbName,
uniqueId
});
// First get the user node from profile context
const userNode = await this.getDefaultNode('profile', userDbName);
if (!userNode || !userNode.data) {
throw new Error('Failed to fetch user node or node data missing');
}
logger.debug('neo4j-service', '✅ Found user node', {
nodeId: userNode.id,
type: userNode.type,
hasData: !!userNode.data,
userDbName,
workerDbName
});
// Initialize result structure
const processedNodes: ProcessedUserNodes = {
privateUserNode: {
...userNode.data,
__primarylabel__: 'User' as const,
title: userNode.data.user_email || 'User',
w: 200,
h: 200,
headerColor: '#3e6589',
backgroundColor: '#f0f0f0',
isLocked: false
} as CCUserNodeProps,
connectedNodes: {}
};
try {
// Get calendar node from calendar context
const calendarNode = await this.getDefaultNode('calendar', userDbName);
if (calendarNode?.data) {
processedNodes.connectedNodes.calendar = {
...calendarNode.data,
__primarylabel__: 'Calendar' as const,
title: calendarNode.data.calendar_name || 'Calendar',
w: 200,
h: 200,
headerColor: '#3e6589',
backgroundColor: '#f0f0f0',
isLocked: false
} as CCCalendarNodeProps;
logger.debug('neo4j-service', '✅ Found calendar node', {
nodeId: calendarNode.id,
tldraw_snapshot: calendarNode.data.tldraw_snapshot
});
} else {
logger.debug('neo4j-service', '️ No calendar node found');
}
} catch (error) {
logger.warn('neo4j-service', '⚠️ Failed to fetch calendar node:', error);
// Continue without calendar node
}
// Get teacher node from teaching context if worker database is available
if (workerDbName) {
try {
const teacherNode = await this.getDefaultNode('teaching', userDbName);
if (teacherNode?.data) {
processedNodes.connectedNodes.teacher = {
...teacherNode.data,
__primarylabel__: 'Teacher' as const,
title: teacherNode.data.teacher_name_formal || 'Teacher',
w: 200,
h: 200,
headerColor: '#3e6589',
backgroundColor: '#f0f0f0',
isLocked: false,
user_db_name: userDbName,
school_db_name: workerDbName
} as CCTeacherNodeProps;
logger.debug('neo4j-service', '✅ Found teacher node', {
nodeId: teacherNode.id,
tldraw_snapshot: teacherNode.data.tldraw_snapshot,
userDbName,
workerDbName
});
} else {
logger.debug('neo4j-service', '️ No teacher node found');
}
} catch (error) {
logger.warn('neo4j-service', '⚠️ Failed to fetch teacher node:', error);
// Continue without teacher node
}
}
logger.debug('neo4j-service', '✅ Processed all user nodes', {
hasUserNode: !!processedNodes.privateUserNode,
hasCalendar: !!processedNodes.connectedNodes.calendar,
hasTeacher: !!processedNodes.connectedNodes.teacher,
teacherData: processedNodes.connectedNodes.teacher ? {
unique_id: processedNodes.connectedNodes.teacher.unique_id,
school_db_name: processedNodes.connectedNodes.teacher.school_db_name,
tldraw_snapshot: processedNodes.connectedNodes.teacher.tldraw_snapshot
} : null
});
return processedNodes;
} catch (error: unknown) {
if (error instanceof Error) {
logger.error('neo4j-service', '❌ Failed to fetch user nodes:', error.message);
} else {
logger.error('neo4j-service', '❌ Failed to fetch user nodes:', String(error));
}
throw error;
}
}
static getUserDatabaseName(userType: string, username: string): string {
return DatabaseNameService.getUserPrivateDB(userType, username);
}
static getSchoolDatabaseName(schoolId: string): string {
return DatabaseNameService.getSchoolPrivateDB(schoolId);
}
static getDefaultSchoolDatabaseName(): string {
return DatabaseNameService.getDevelopmentSchoolDB();
}
static async fetchNodeData(nodeId: string, dbName: string): Promise<{ node_type: string; node_data: NodeResponse['nodes']['userNode'] } | null> {
try {
logger.debug('neo4j-service', '🔄 Fetching node data', { nodeId, dbName });
const response = await axiosInstance.get<{
status: string;
node: {
node_type: string;
node_data: NodeResponse['nodes']['userNode'];
};
}>('/database/tools/get-node', {
params: {
unique_id: nodeId,
db_name: dbName
}
});
if (response.data?.status === 'success' && response.data.node) {
return response.data.node;
}
return null;
} catch (error) {
logger.error('neo4j-service', '❌ Failed to fetch node data:', error);
throw error;
}
}
static getNodeDatabaseName(node: NavigationNode): string {
// If the node path starts with /node_filesystem/users/, it's in a user database
if (node.tldraw_snapshot.startsWith('/node_filesystem/users/')) {
const parts = node.tldraw_snapshot.split('/');
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
return parts[3];
}
// For school/worker nodes, extract from the path or use a default
if (node.tldraw_snapshot.includes('/schools/')) {
return `cc.institutes.${DEV_SCHOOL_UUID}`;
}
// Default to user database if we can't determine
return node.tldraw_snapshot.split('/')[3];
}
static async getDefaultNode(context: NodeContext, dbName: string): Promise<NavigationNode | null> {
try {
logger.debug('neo4j-service', '🔄 Fetching default node', { context, dbName });
// For overview context, we need to extract the base context from the current navigation state
const params: Record<string, string> = { db_name: dbName };
if (context === 'overview') {
// Get the current base context from the navigation store
const navigationStore = useNavigationStore.getState();
params.base_context = navigationStore.context.base;
}
const response = await axiosInstance.get<DefaultNodeResponse>(
`/database/tools/get-default-node/${context}`,
{ params }
);
if (response.data?.status === 'success' && response.data.node) {
return {
id: response.data.node.id,
tldraw_snapshot: response.data.node.tldraw_snapshot,
type: response.data.node.type,
label: response.data.node.label,
data: response.data.node.data
};
}
return null;
} catch (error) {
logger.error('neo4j-service', '❌ Failed to fetch default node:', error);
throw error;
}
}
static async fetchCalendarStructure(dbName: string): Promise<CalendarStructureResponse['data']> {
try {
logger.debug('navigation', '🔄 Fetching calendar structure', { dbName });
const response = await axiosInstance.get<CalendarStructureResponse>(
`/database/calendar-structure/get-calendar-structure?db_name=${dbName}`
);
if (response.data.status === 'success') {
logger.info('navigation', '✅ Calendar structure fetched successfully');
return response.data.data;
}
throw new Error('Failed to fetch calendar structure');
} catch (error) {
logger.error('navigation', '❌ Failed to fetch calendar structure:', error);
throw error;
}
}
static async fetchWorkerStructure(dbName: string): Promise<WorkerStructureResponse['data']> {
try {
logger.debug('navigation', '🔄 Fetching worker structure', { dbName });
const response = await axiosInstance.get<WorkerStructureResponse>(
`/database/worker-structure/get-worker-structure?db_name=${dbName}`
);
if (response.data.status === 'success') {
logger.info('navigation', '✅ Worker structure fetched successfully');
return response.data.data;
}
throw new Error('Failed to fetch worker structure');
} catch (error) {
logger.error('navigation', '❌ Failed to fetch worker structure:', error);
throw error;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { logger } from '../debugConfig';
import Modal from 'react-modal';
let isInitialized = false;
export const initializeApp = () => {
if (isInitialized) {
return;
}
logger.debug('app', '🚀 App initializing', {
isDevMode: import.meta.env.VITE_DEV === 'true',
environment: import.meta.env.MODE,
appName: import.meta.env.VITE_APP_NAME
});
// Set the app element for react-modal
Modal.setAppElement('#root');
isInitialized = true;
};
export const resetInitialization = () => {
isInitialized = false;
};
+11
View File
@@ -0,0 +1,11 @@
import axios from '../../axiosConfig';
export const sendPrompt = async (data: { model: string, prompt: string }) => {
const response = await axios.post('/llm/ollama_text_prompt', data);
return response.data;
};
export const sendVisionPrompt = async (data: { model: string, imagePath: string, prompt: string }) => {
const response = await axios.post('/llm/ollama_vision_prompt', data);
return response.data;
};
+146
View File
@@ -0,0 +1,146 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
// Define custom theme options
const themeOptions: ThemeOptions = {
palette: {
primary: {
main: '#1976d2',
light: '#42a5f5',
dark: '#1565c0',
contrastText: '#ffffff',
},
secondary: {
main: '#dc004e',
light: '#ff4081',
dark: '#c51162',
contrastText: '#ffffff',
},
error: {
main: '#f44336',
light: '#e57373',
dark: '#d32f2f',
},
warning: {
main: '#ff9800',
light: '#ffb74d',
dark: '#f57c00',
},
info: {
main: '#2196f3',
light: '#64b5f6',
dark: '#1976d2',
},
success: {
main: '#4caf50',
light: '#81c784',
dark: '#388e3c',
},
background: {
default: '#f5f5f5',
paper: '#ffffff',
},
text: {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.6)',
disabled: 'rgba(0, 0, 0, 0.38)',
},
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
h1: {
fontSize: '2.5rem',
fontWeight: 500,
},
h2: {
fontSize: '2rem',
fontWeight: 500,
},
h3: {
fontSize: '1.75rem',
fontWeight: 500,
},
h4: {
fontSize: '1.5rem',
fontWeight: 500,
},
h5: {
fontSize: '1.25rem',
fontWeight: 500,
},
h6: {
fontSize: '1rem',
fontWeight: 500,
},
body1: {
fontSize: '1rem',
lineHeight: 1.5,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.43,
},
},
shape: {
borderRadius: 4,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: '4px',
padding: '6px 16px',
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0px 2px 4px -1px rgba(0,0,0,0.2)',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '4px',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '8px',
boxShadow: '0px 2px 4px -1px rgba(0,0,0,0.1)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: '0px 1px 3px rgba(0,0,0,0.12)',
},
},
},
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920,
},
},
};
export const theme = createTheme(themeOptions);
+82
View File
@@ -0,0 +1,82 @@
import {
TLStore,
createTLStore,
TLEditorSnapshot,
loadSnapshot,
TLAnyShapeUtilConstructor,
TLAnyBindingUtilConstructor,
TLSchema
} from '@tldraw/tldraw';
import { LoadingState } from './snapshotService';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { logger } from '../../debugConfig';
import { customSchema } from '../../utils/tldraw/schemas';
interface LocalStoreConfig {
shapeUtils?: TLAnyShapeUtilConstructor[];
bindingUtils?: TLAnyBindingUtilConstructor[];
schema?: TLSchema;
}
class LocalStoreService {
private store: TLStore | null = null;
private static instance: LocalStoreService;
public static getInstance(): LocalStoreService {
if (!LocalStoreService.instance) {
LocalStoreService.instance = new LocalStoreService();
}
return LocalStoreService.instance;
}
public getStore(config?: LocalStoreConfig): TLStore {
if (!this.store) {
logger.debug('system', '🔄 Creating new TLStore');
this.store = createTLStore({
shapeUtils: config?.shapeUtils || allShapeUtils,
bindingUtils: config?.bindingUtils || allBindingUtils,
schema: config?.schema || customSchema,
});
}
return this.store;
}
public async loadSnapshot(
snapshot: Partial<TLEditorSnapshot>,
setLoadingState: (state: LoadingState) => void
): Promise<void> {
try {
if (!this.store) {
throw new Error('Store not initialized');
}
logger.debug('system', '📥 Loading snapshot into store');
loadSnapshot(this.store, snapshot);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('system', '❌ Failed to load snapshot:', error);
if (this.store) {
this.store.clear();
}
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load snapshot'
});
}
}
public clearStore(): void {
logger.debug('system', '🧹 Clearing store');
if (this.store) {
this.store.clear();
}
this.store = null;
}
public isStoreReady(): boolean {
return !!this.store;
}
}
export const localStoreService = LocalStoreService.getInstance();
+213
View File
@@ -0,0 +1,213 @@
import { Editor, TLShape, createShapeId } from '@tldraw/tldraw';
import { logger } from '../../debugConfig';
import { NavigationNode } from '../../types/navigation';
import { NeoShapeService } from '../graph/neoShapeService';
import { NodeData } from '../../types/graph-shape';
export class NodeCanvasService {
private static readonly CANVAS_PADDING = 100;
private static readonly ANIMATION_DURATION = 500;
private static currentAnimation: number | null = null;
private static findAllNodeShapes(editor: Editor, nodeId: string): TLShape[] {
const shapes = editor.getCurrentPageShapes();
const exactShapeId = `shape:${nodeId}`;
// Filter shapes with exact ID match only
return shapes.filter((shape: TLShape) => {
const shapeId = shape.id.toString();
return shapeId === exactShapeId || shapeId === nodeId;
});
}
private static handleMultipleNodeInstances(editor: Editor, nodeId: string, shapes: TLShape[]): TLShape | undefined {
if (shapes.length > 1) {
logger.warn('node-canvas', '⚠️ Multiple instances of node found', {
nodeId,
count: shapes.length,
shapes: shapes.map(s => s.id)
});
// Return the first instance but log a warning for the user
return shapes[0];
}
return shapes[0];
}
private static cancelCurrentAnimation(): void {
if (this.currentAnimation !== null) {
cancelAnimationFrame(this.currentAnimation);
this.currentAnimation = null;
}
}
private static animateViewToShape(editor: Editor, shape: TLShape): void {
// Cancel any existing animation
this.cancelCurrentAnimation();
const bounds = editor.getShapePageBounds(shape);
if (!bounds) {
logger.warn('node-canvas', '⚠️ Could not get shape bounds', { shapeId: shape.id });
return;
}
// Get the current viewport and camera state
const viewportBounds = editor.getViewportPageBounds();
const camera = editor.getCamera();
const currentPage = editor.getCurrentPage();
// Calculate the center point of the shape in page coordinates
const shapeCenterX = bounds.x + bounds.w / 2;
const shapeCenterY = bounds.y + bounds.h / 2;
// Calculate where the shape currently appears in the viewport
const currentViewportCenterX = viewportBounds.x + viewportBounds.w / 2;
const currentViewportCenterY = viewportBounds.y + viewportBounds.h / 2;
// Check if the shape is already reasonably centered
const tolerance = 50; // pixels
const isAlreadyCentered =
Math.abs(shapeCenterX - currentViewportCenterX) < tolerance &&
Math.abs(shapeCenterY - currentViewportCenterY) < tolerance;
// Log the current state for debugging
logger.debug('node-canvas', '📊 Current canvas state', {
page: {
id: currentPage.id,
name: currentPage.name,
shapes: editor.getCurrentPageShapes().length
},
camera: {
current: camera,
viewport: viewportBounds
},
shape: {
id: shape.id,
bounds,
center: { x: shapeCenterX, y: shapeCenterY },
currentViewportCenter: { x: currentViewportCenterX, y: currentViewportCenterY },
isAlreadyCentered
}
});
// If the shape is already centered, don't animate
if (isAlreadyCentered) {
logger.debug('node-canvas', '✨ Shape is already centered, skipping animation');
return;
}
// Calculate the target camera position to center the shape
const targetX = camera.x + (currentViewportCenterX - shapeCenterX);
const targetY = camera.y + (currentViewportCenterY - shapeCenterY);
const startX = camera.x;
const startY = camera.y;
// Force the camera to maintain its current zoom level
const currentZoom = camera.z;
// Animate the camera position
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / this.ANIMATION_DURATION, 1);
// Use easeInOutCubic for smooth animation
const eased = progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
const x = startX + (targetX - startX) * eased;
const y = startY + (targetY - startY) * eased;
editor.setCamera({
...camera,
x,
y,
z: currentZoom // Maintain zoom level
});
if (progress < 1) {
this.currentAnimation = requestAnimationFrame(animate);
} else {
this.currentAnimation = null;
logger.debug('node-canvas', '✅ Shape centering animation complete', {
finalPosition: { x, y, z: currentZoom },
shapeCenterPoint: { x: shapeCenterX, y: shapeCenterY }
});
}
};
this.currentAnimation = requestAnimationFrame(animate);
}
static async centerCurrentNode(editor: Editor, node: NavigationNode, nodeData: NodeData): Promise<void> {
try {
// Cancel any existing animation before starting
this.cancelCurrentAnimation();
const shapes = this.findAllNodeShapes(editor, node.id);
if (shapes.length > 0) {
const existingShape = this.handleMultipleNodeInstances(editor, node.id, shapes);
if (existingShape) {
// Ensure the shape is actually on the canvas
const bounds = editor.getShapePageBounds(existingShape);
if (!bounds) {
logger.warn('node-canvas', '⚠️ Shape exists but has no bounds', {
nodeId: node.id,
shapeId: existingShape.id
});
return;
}
this.animateViewToShape(editor, existingShape);
logger.debug('node-canvas', '🎯 Centered view on existing shape', {
nodeId: node.id,
shapeBounds: bounds
});
}
} else {
// Create new shape for the node
const newShape = await this.createNodeShape(editor, node, nodeData);
if (newShape) {
this.animateViewToShape(editor, newShape);
logger.debug('node-canvas', '✨ Created and centered new shape', { nodeId: node.id });
} else {
logger.warn('node-canvas', '⚠️ Could not create or center node shape', { nodeId: node.id });
}
}
} catch (error) {
this.cancelCurrentAnimation();
logger.error('node-canvas', '❌ Failed to center node', {
nodeId: node.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
private static async createNodeShape(editor: Editor, node: NavigationNode, nodeData: NodeData): Promise<TLShape | null> {
try {
const viewportBounds = editor.getViewportPageBounds();
const centerX = viewportBounds.x + viewportBounds.w / 2;
const centerY = viewportBounds.y + viewportBounds.h / 2;
// Get shape configuration from NeoShapeService
const shapeConfig = NeoShapeService.getShapeConfig(node, nodeData, centerX, centerY);
const shapeId = createShapeId(node.id);
// Create the shape with the configuration
editor.createShape<TLShape>({
id: shapeId,
...shapeConfig
});
return editor.getShape(shapeId) || null;
} catch (error) {
logger.error('node-canvas', '❌ Failed to create node shape', {
nodeId: node.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
return null;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { TldrawOptions } from "@tldraw/tldraw";
export const multiplayerOptions: Partial<TldrawOptions> = {
actionShortcutsLocation: "swap",
adjacentShapeMargin: 10,
animationMediumMs: 320,
cameraMovingTimeoutMs: 64,
cameraSlideFriction: 0.09,
coarseDragDistanceSquared: 36,
coarseHandleRadius: 20,
coarsePointerWidth: 12,
collaboratorCheckIntervalMs: 1200,
collaboratorIdleTimeoutMs: 3000,
collaboratorInactiveTimeoutMs: 60000,
defaultSvgPadding: 32,
doubleClickDurationMs: 450,
dragDistanceSquared: 16,
edgeScrollDelay: 200,
edgeScrollDistance: 8,
edgeScrollEaseDuration: 200,
edgeScrollSpeed: 25,
flattenImageBoundsExpand: 64,
flattenImageBoundsPadding: 16,
followChaseViewportSnap: 2,
gridSteps: [
{ mid: 0.15, min: -1, step: 64 },
{ mid: 0.375, min: 0.05, step: 16 },
{ mid: 1, min: 0.15, step: 4 },
{ mid: 2.5, min: 0.7, step: 1 }
],
handleRadius: 12,
hitTestMargin: 8,
laserDelayMs: 1200,
longPressDurationMs: 500,
maxExportDelayMs: 5000,
maxFilesAtOnce: 100,
maxPages: 1,
maxPointsPerDrawShape: 500,
maxShapesPerPage: 4000,
multiClickDurationMs: 200,
temporaryAssetPreviewLifetimeMs: 180000,
textShadowLod: 0.35
}
+234
View File
@@ -0,0 +1,234 @@
import { Editor, TLStoreEventInfo, createShapeId, TLShape } from '@tldraw/tldraw'
import { logger } from '../../debugConfig'
import { CCSlideShowShape } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideShowShapeUtil'
import { CCSlideShape } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideShapeUtil'
import { CCSlideLayoutBinding } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideLayoutBindingUtil'
export class PresentationService {
private editor: Editor
private initialSlideshow: CCSlideShowShape | null = null
private cameraProxyId = createShapeId('camera-proxy')
private lastUserInteractionTime = 0
private readonly USER_INTERACTION_DEBOUNCE = 1000 // 1 second
private zoomLevels = new Map<string, number>() // Track zoom levels by shape dimensions
private isMoving = false
constructor(editor: Editor) {
this.editor = editor
logger.debug('system', '🎥 PresentationService initialized')
// Add style to hide camera proxy frame
const style = document.createElement('style')
style.setAttribute('data-camera-proxy', this.cameraProxyId)
style.textContent = `
[data-shape-id="${this.cameraProxyId}"] {
opacity: 0 !important;
pointer-events: none !important;
}
`
document.head.appendChild(style)
}
private getShapeDimensionKey(width: number, height: number): string {
return `${Math.round(width)}_${Math.round(height)}`
}
private async moveToShape(shape: CCSlideShape | CCSlideShowShape): Promise<void> {
if (this.isMoving) {
logger.debug('presentation', '⏳ Movement in progress, queueing next movement')
// Wait for current movement to complete
await new Promise(resolve => setTimeout(resolve, 100))
return this.moveToShape(shape)
}
this.isMoving = true
const bounds = this.editor.getShapePageBounds(shape.id)
if (!bounds) {
logger.warn('presentation', '⚠️ Could not get bounds for shape')
this.isMoving = false
return
}
try {
// Phase 1: Update proxy shape instantly
this.editor.updateShape({
id: this.cameraProxyId,
type: 'frame',
x: bounds.minX,
y: bounds.minY,
props: {
w: bounds.width,
h: bounds.height,
name: 'camera-proxy'
}
})
// Wait for a frame to ensure bounds are updated
await new Promise(resolve => requestAnimationFrame(resolve))
// Phase 2: Calculate and apply camera movement
const viewport = this.editor.getViewportPageBounds()
const padding = 32
const dimensionKey = this.getShapeDimensionKey(bounds.width, bounds.height)
// Get existing zoom level for this shape size or calculate new one
let targetZoom = this.zoomLevels.get(dimensionKey)
if (!targetZoom) {
targetZoom = Math.min(
(viewport.width - padding * 2) / bounds.width,
(viewport.height - padding * 2) / bounds.height
)
this.zoomLevels.set(dimensionKey, targetZoom)
logger.debug('presentation', '📏 New zoom level calculated', {
dimensions: dimensionKey,
zoom: targetZoom
})
}
// Stop any existing camera movement
this.editor.stopCameraAnimation()
// Move camera to new position
this.editor.zoomToBounds(bounds, {
animation: {
duration: 500,
easing: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
},
targetZoom,
inset: padding
})
// Wait for animation to complete
await new Promise(resolve => setTimeout(resolve, 500))
} catch (error) {
logger.error('presentation', '❌ Error during shape transition', { error })
} finally {
this.isMoving = false
}
}
startPresentationMode() {
logger.info('presentation', '🎥 Starting presentation mode')
// Reset zoom levels on start
this.zoomLevels.clear()
// Find initial slideshow to track
const slideshows = this.editor.getSortedChildIdsForParent(this.editor.getCurrentPageId())
.map(id => this.editor.getShape(id))
.filter(shape => shape?.type === 'cc-slideshow')
if (slideshows.length === 0) {
logger.warn('presentation', '⚠️ No slideshows found')
return () => {}
}
this.initialSlideshow = slideshows[0] as CCSlideShowShape
// Create camera proxy shape if it doesn't exist
if (!this.editor.getShape(this.cameraProxyId)) {
this.editor.createShape({
id: this.cameraProxyId,
type: 'frame',
x: 0,
y: 0,
props: {
w: 1,
h: 1,
name: 'camera-proxy'
}
})
}
const handleStoreChange = (event: TLStoreEventInfo) => {
// Debounce user interaction logs
if (event.source === 'user') {
const now = Date.now()
if (now - this.lastUserInteractionTime > this.USER_INTERACTION_DEBOUNCE) {
logger.debug('presentation', '📝 User interaction received')
this.lastUserInteractionTime = now
}
}
if (!event.changes.updated) return
// Only process shape updates
const shapeUpdates = Object.entries(event.changes.updated)
.filter(([, [from, to]]) =>
from.typeName === 'shape' &&
to.typeName === 'shape' &&
(from as TLShape).type === 'cc-slideshow' &&
(to as TLShape).type === 'cc-slideshow'
)
if (shapeUpdates.length === 0) return
for (const [, [from, to]] of shapeUpdates) {
const fromShape = from as TLShape
const toShape = to as TLShape
if (!this.initialSlideshow || fromShape.id !== this.initialSlideshow.id) continue
const fromShow = fromShape as CCSlideShowShape
const toShow = toShape as CCSlideShowShape
if (fromShow.props.currentSlideIndex === toShow.props.currentSlideIndex) continue
logger.info('presentation', '🔄 Moving to new slide', {
from: fromShow.props.currentSlideIndex,
to: toShow.props.currentSlideIndex
})
// Get all bindings for this slideshow, sorted by index
const bindings = this.editor
.getBindingsFromShape(toShow, 'cc-slide-layout')
.filter((b): b is CCSlideLayoutBinding => b.type === 'cc-slide-layout')
.filter(b => !b.props.placeholder)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const currentBinding = bindings[toShow.props.currentSlideIndex]
if (!currentBinding) {
logger.warn('presentation', '⚠️ Could not find binding for target slide')
continue
}
const currentSlide = this.editor.getShape(currentBinding.toId) as CCSlideShape
if (!currentSlide) {
logger.warn('presentation', '⚠️ Could not find target slide')
continue
}
void this.moveToShape(currentSlide)
}
}
// Set up store listener and get cleanup function
const storeCleanup = this.editor.store.listen(handleStoreChange)
// Return cleanup function
return () => {
logger.info('presentation', '🧹 Running presentation mode cleanup')
storeCleanup()
this.stopPresentationMode()
}
}
stopPresentationMode() {
this.zoomLevels.clear()
this.isMoving = false
if (this.editor.getShape(this.cameraProxyId)) {
this.editor.deleteShape(this.cameraProxyId)
}
// Remove the style element
const style = document.querySelector(`style[data-camera-proxy="${this.cameraProxyId}"]`)
if (style) {
style.remove()
}
}
// Public method to move to any shape (slide or slideshow)
zoomToShape(shape: CCSlideShape | CCSlideShowShape) {
void this.moveToShape(shape)
}
}
+52
View File
@@ -0,0 +1,52 @@
interface SearXNGResult {
title?: string
url?: string
content?: string
}
export interface SearchResult {
title: string;
url: string;
content?: string;
}
export class SearchService {
static async search(query: string): Promise<SearchResult[]> {
if (!query.trim()) {
return []
}
try {
const searchParams = new URLSearchParams({
q: query,
format: 'json',
language: 'en',
time_range: 'None',
safesearch: '0',
engines: 'google,bing,duckduckgo',
})
const url = `${import.meta.env.VITE_FRONTEND_SITE_URL}/searxng-api/search?${searchParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Search failed with status: ${response.status}`)
}
const data = await response.json()
return (data.results || []).map((result: SearXNGResult) => ({
title: result.title || '',
url: result.url || '',
content: result.content || ''
}))
} catch (error) {
console.error('Search error:', error)
throw error
}
}
}
+122
View File
@@ -0,0 +1,122 @@
// External imports
import { TLStore, TLEditorSnapshot, loadSnapshot, getSnapshot } from '@tldraw/tldraw';
// Local imports
import { logger } from '../../debugConfig';
import { LoadingState } from './snapshotService';
import { storageService, StorageKeys } from '../auth/localStorageService';
interface AutoSaveConfig {
checkInterval: number; // Changed to required
saveInterval: number; // Changed to required
}
const DEFAULT_CONFIG: AutoSaveConfig = {
checkInterval: 5000,
saveInterval: 30000
};
export class SharedStoreService {
private lastSaveTime: number = Date.now();
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
private config: AutoSaveConfig;
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
this.config = {
...DEFAULT_CONFIG,
...config
};
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
}
public startAutoSave(setLoadingState: (state: LoadingState) => void): void {
if (this.autoSaveInterval) {
this.stopAutoSave();
}
this.autoSaveInterval = setInterval(() => {
this.checkAndSave(setLoadingState);
}, this.config.checkInterval);
logger.debug('shared-store-service', '⏰ Auto-save started', {
checkInterval: this.config.checkInterval,
saveInterval: this.config.saveInterval
});
}
public stopAutoSave(): void {
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
this.autoSaveInterval = null;
logger.debug('shared-store-service', '⏹️ Auto-save stopped');
}
}
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
const now = Date.now();
if (now - this.lastSaveTime >= this.config.saveInterval) {
const currentSnapshot = getSnapshot(this.store);
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
await this.saveSnapshot(currentSnapshot, setLoadingState);
this.lastSaveTime = now;
} else {
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
}
}
}
public async saveSnapshot(
snapshot: Partial<TLEditorSnapshot>,
setLoadingState: (state: LoadingState) => void
): Promise<void> {
try {
storageService.set(StorageKeys.LOCAL_SNAPSHOT, snapshot);
setLoadingState({ status: 'ready', error: '' });
logger.debug('shared-store-service', '✅ Snapshot saved successfully');
} catch (error) {
logger.error('shared-store-service', '❌ Failed to save snapshot:', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to save snapshot'
});
}
}
public async loadSnapshot(
snapshot: Partial<TLEditorSnapshot>,
setLoadingState: (state: LoadingState) => void
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
loadSnapshot(this.store, snapshot);
setLoadingState({ status: 'ready', error: '' });
logger.debug('shared-store-service', '✅ Snapshot loaded successfully');
} catch (error) {
logger.error('shared-store-service', '❌ Failed to load snapshot:', error);
this.store.clear();
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load snapshot'
});
}
}
public getStore(): TLStore {
return this.store;
}
public clear(): void {
this.stopAutoSave();
this.store.clear();
logger.debug('shared-store-service', '🧹 Store cleared');
}
}
export const createSharedStore = (
store: TLStore,
config?: Partial<AutoSaveConfig>
): SharedStoreService => {
return new SharedStoreService(store, config);
};
+314
View File
@@ -0,0 +1,314 @@
// External imports
import { loadSnapshot, TLStore, getSnapshot } 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: '',
tldraw_snapshot: '',
type: '',
label: ''
};
export class NavigationSnapshotService {
private store: TLStore;
private currentNodePath: string | null = null;
private isAutoSaveEnabled = true;
private isSaving = false;
private isLoading = false;
private pendingOperation: { save?: string; load?: string } | null = null;
private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(store: TLStore) {
this.store = store;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id
});
}
private static replaceBackslashes(input: string | undefined): string {
return input ? input.replace(/\\/g, '/') : '';
}
static async loadNodeSnapshotFromDatabase(
nodePath: string,
dbName: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
logger.info('snapshot-service', '📂 Loading file from path', {
path: nodePath,
db_name: dbName
});
const response = await axios.get(
'/database/tldraw_fs/get_tldraw_node_file', {
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
}
}
);
const snapshot = response.data;
if (snapshot && snapshot.document && snapshot.session) {
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState);
} else {
loadSnapshot(store, snapshot);
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
}
} else {
logger.error('snapshot-service', '❌ Invalid snapshot format');
setLoadingState({ status: 'error', error: 'Invalid snapshot format' });
}
} catch (error) {
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
error: error instanceof Error ? error.message : 'Unknown error'
});
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load file'
});
}
}
static async saveNodeSnapshotToDatabase(
nodePath: string,
dbName: string,
store: TLStore
): Promise<void> {
try {
logger.info('snapshot-service', '💾 Saving snapshot to database', {
path: nodePath,
db_name: dbName
});
const snapshot = getSnapshot(store);
const response = await axios.post(
'/database/tldraw_fs/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');
}
} catch (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<void> {
if (!this.currentNodePath || this.currentNodePath !== nodePath) {
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
currentPath: this.currentNodePath,
savePath: nodePath
});
return;
}
try {
this.isSaving = true;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = user.user_db_name;
logger.debug('snapshot-service', '💾 Saving snapshot', {
nodePath,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store);
logger.debug('snapshot-service', '✅ Saved navigation snapshot', {
nodePath,
storeId: this.store.id
});
} catch (error) {
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath
});
throw error;
} finally {
this.isSaving = false;
}
}
private async loadSnapshotForNode(node: NavigationNode): Promise<void> {
try {
this.isLoading = true;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = user.user_db_name;
logger.debug('snapshot-service', '📥 Loading snapshot', {
nodePath: node.tldraw_snapshot,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.tldraw_snapshot,
dbName,
this.store,
(state: LoadingState) => {
if (state.status === 'ready') {
this.currentNodePath = node.tldraw_snapshot;
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
nodePath: node.tldraw_snapshot
});
} else if (state.status === 'error') {
logger.error('snapshot-service', '❌ Error in load callback', {
error: state.error,
nodePath: node.tldraw_snapshot
});
}
}
);
} catch (error) {
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath: node.tldraw_snapshot
});
throw error;
} finally {
this.isLoading = false;
}
}
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
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
return new Promise((resolve) => {
this.debounceTimeout = setTimeout(async () => {
try {
await this.executeNavigation(fromNode || EMPTY_NODE, toNode);
resolve();
} catch (error) {
logger.error('snapshot-service', '❌ Navigation failed', error);
throw error;
}
}, 100); // 100ms debounce
});
}
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
try {
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
from: fromNode.tldraw_snapshot,
to: toNode.tldraw_snapshot,
currentPath: this.currentNodePath
});
// If we're already in a navigation operation, queue this one
if (this.isSaving || this.isLoading) {
this.pendingOperation = {
save: fromNode.tldraw_snapshot || undefined,
load: toNode.tldraw_snapshot
};
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
return;
}
// Clear the store before loading new snapshot
logger.debug('snapshot-service', '🔄 Clearing store');
this.currentNodePath = null;
logger.debug('snapshot-service', '🧹 Cleared current node path');
// Load the new node's snapshot
if (toNode.tldraw_snapshot) {
await this.loadSnapshotForNode(toNode);
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
nodePath: toNode.tldraw_snapshot
});
}
// 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, tldraw_snapshot: operation.save } : null,
operation.load ? { ...EMPTY_NODE, tldraw_snapshot: 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.tldraw_snapshot,
toPath: toNode.tldraw_snapshot
});
throw error;
}
}
setAutoSave(enabled: boolean): void {
this.isAutoSaveEnabled = enabled;
logger.debug('snapshot-service', '🔄 Auto-save setting changed', {
enabled
});
}
getCurrentNodePath(): string | null {
return this.currentNodePath;
}
async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath);
}
}
clearCurrentNode(): void {
this.currentNodePath = null;
this.store.clear();
logger.debug('snapshot-service', '🧹 Cleared current node and store');
}
}
+117
View File
@@ -0,0 +1,117 @@
// External imports
import {
TLAssetStore,
uniqueId,
TLAsset,
TLBookmarkAsset,
AssetRecordType,
getHashForString,
} from '@tldraw/tldraw';
import { logger } from '../../debugConfig';
export interface SyncConnectionOptions {
userId: string;
displayName: string;
color: string;
roomId?: string;
baseUrl: string;
}
export function createSyncConnectionOptions(options: SyncConnectionOptions) {
const {
userId,
displayName,
roomId = 'multiplayer',
baseUrl
} = options;
// Ensure we have valid user info
if (!userId || !displayName) {
logger.warn('sync-service', 'Missing user information', { userId, displayName });
}
// Create a unique room ID if not provided
const effectiveRoomId = roomId || `room-${uniqueId()}`;
const multiplayerAssets: TLAssetStore = {
async upload(_asset: unknown, file: File) {
const id = uniqueId();
const objectName = `${id}-${file.name}`;
const uploadPath = '/uploads';
const url = `${baseUrl}${uploadPath}/${encodeURIComponent(objectName)}`;
try {
const response = await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
});
if (!response.ok) {
const errorDetail = await response.text();
throw new Error(`Failed to upload asset: ${response.statusText} - Details: ${errorDetail}`);
}
return url;
} catch (error) {
logger.error('sync-service', 'Error during asset upload: ', error);
throw error;
}
},
resolve(asset: TLAsset) {
return asset.props.src ?? '';
}
};
logger.info('sync-service', '🔄 Creating sync connection', {
userId,
displayName,
roomId: effectiveRoomId
});
return {
uri: `${baseUrl}/connect/${effectiveRoomId}`,
assets: multiplayerAssets,
roomId: effectiveRoomId
};
}
export async function handleExternalAsset(baseUrl: string, url: string): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: '',
image: '',
favicon: '',
title: ''
},
meta: {}
};
try {
const response = await fetch(`${baseUrl}/unfurl?url=${encodeURIComponent(url)}`);
const data = await response.json();
asset.props = {
...asset.props,
...data
};
} catch (error) {
logger.error('sync-service', 'Error unfurling URL:', error);
}
return asset;
}
export function generateSharedRoomId(path: string): string {
// Create a deterministic room ID based on the path
const sanitizedPath = path.replace(/[^a-zA-Z0-9]/g, '-');
return `shared-${sanitizedPath}`;
}
@@ -0,0 +1,29 @@
import axios from '../../../axiosConfig';
interface TranscriptLine {
start: number;
duration: number;
text: string;
}
export async function getYoutubeTranscript(videoUrl: string): Promise<TranscriptLine[]> {
try {
const videoId = extractVideoId(videoUrl);
if (!videoId) {
throw new Error('Invalid YouTube URL');
}
const response = await axios.get(`/external/youtube-proxy?videoId=${videoId}`);
console.log('Got Youtube video data:', response.data);
return response.data.transcript;
} catch (error) {
console.error('Error fetching YouTube video data:', error);
throw error;
}
}
export function extractVideoId(url: string): string | null {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}