Initial commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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 || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user