This commit is contained in:
Kevin Carter 2025-11-14 14:47:26 +00:00
parent 69ecf2c7c1
commit 3b4876793e
104 changed files with 231519 additions and 1031 deletions

30
.env
View File

@ -1,14 +1,18 @@
VITE_APP_URL=app.classroomcopilot.ai
VITE_FRONTEND_SITE_URL=classroomcopilot.ai
VITE_APP_PROTOCOL=https
VITE_APP_NAME=Classroom Copilot
VITE_SUPER_ADMIN_EMAIL=kcar@kevlarai.com
VITE_DEV=false
VITE_SUPABASE_URL=https://supa.classroomcopilot.ai
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzM0OTg4MzkxLCJpc3MiOiJzdXBhYmFzZSIsImV4cCI6MTc2NjUyNDM5MSwicm9sZSI6ImFub24ifQ.utdDZzVlhYIc-cSXuC2kyZz7HN59YfyMH4eaOw1hRlk
VITE_APP_API_URL=https://api.classroomcopilot.ai
VITE_STRICT_MODE=false
PORT_FRONTEND=5173
PORT_FRONTEND_HMR=3002
PORT_API=800
PORT_SUPABASE=8000
APP_PROTOCOL=https
APP_URL=app.classroomcopilot.ai
PORT_FRONTEND=3000
HOST_FRONTEND=localhost:5173
VITE_PORT_FRONTEND=5173
VITE_PORT_FRONTEND_HMR=5173
VITE_APP_NAME=Classroom Copilot
VITE_SUPER_ADMIN_EMAIL=admin@classroomcopilot.ai
VITE_DEV=true
VITE_FRONTEND_SITE_URL=http://localhost:5173
VITE_APP_HMR_URL=http://localhost:5173
VITE_SUPABASE_URL=http://localhost:8000
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
VITE_API_URL=http://localhost:8080
VITE_API_BASE=http://localhost:8080

View File

@ -1,15 +0,0 @@
# Production environment configuration
# These values override .env for production mode
# Disable development features
VITE_DEV=true
VITE_STRICT_MODE=true
# App environment
VITE_SUPER_ADMIN_EMAIL=kcar@kevlarai.com
VITE_FRONTEND_SITE_URL=classroomcopilot.test
VITE_APP_PROTOCOL=http
VITE_SUPABASE_URL=supa.classroomcopilot.test
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzM0OTg4MzkxLCJpc3MiOiJzdXBhYmFzZSIsImV4cCI6MTc2NjUyNDM5MSwicm9sZSI6ImFub24ifQ.utdDZzVlhYIc-cSXuC2kyZz7HN59YfyMH4eaOw1hRlk
VITE_WHISPERLIVE_URL=whisperlive.classroomcopilot.test
VITE_APP_API_URL=api.classroomcopilot.test

View File

View File

@ -1,15 +0,0 @@
# Production environment configuration
# These values override .env for production mode
# Disable development features
VITE_DEV=false
VITE_STRICT_MODE=false
# App environment
VITE_SUPER_ADMIN_EMAIL=kcar@kevlarai.com
VITE_FRONTEND_SITE_URL=classroomcopilot.ai
VITE_APP_PROTOCOL=https
VITE_SUPABASE_URL=supa.classroomcopilot.ai
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzM0OTg4MzkxLCJpc3MiOiJzdXBhYmFzZSIsImV4cCI6MTc2NjUyNDM5MSwicm9sZSI6ImFub24ifQ.utdDZzVlhYIc-cSXuC2kyZz7HN59YfyMH4eaOw1hRlk
VITE_WHISPERLIVE_URL=whisperlive.classroomcopilot.ai
VITE_APP_API_URL=api.classroomcopilot.ai

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
.env

View File

@ -1,41 +0,0 @@
FROM node:20 as builder
WORKDIR /app
COPY package*.json ./
COPY .env.development .env.development
# First generate package-lock.json if it doesn't exist, then do clean install
RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
COPY . .
# Run build with development mode
RUN npm run build -- --mode development
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Create a simple nginx configuration
RUN echo 'server { \
listen 3003; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
expires 30d; \
add_header Cache-Control "public, no-transform"; \
} \
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { \
expires 30d; \
add_header Cache-Control "public, no-transform"; \
} \
location ~ /\. { \
deny all; \
} \
error_page 404 /index.html; \
}' > /etc/nginx/conf.d/default.conf
# Set up permissions
RUN chown -R nginx:nginx /usr/share/nginx/html \
&& chown -R nginx:nginx /var/log/nginx
# Expose HTTP port (NPM will handle HTTPS)
EXPOSE 3003

View File

@ -1,28 +0,0 @@
# Dockerfile.storybook
FROM node:20-slim
WORKDIR /app
# Install basic dependencies
RUN apt-get update && apt-get install -y \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package*.json ./
# Copy yarn.lock if it exists
COPY yarn.lock* ./
# Install dependencies
RUN yarn install
# Copy the rest of the application
COPY . .
# Expose port Storybook runs on
EXPOSE 6006
# Start Storybook in development mode with host configuration
ENV BROWSER=none
CMD ["yarn", "storybook", "dev", "-p", "6006", "--host", "0.0.0.0"]

View File

@ -1,51 +0,0 @@
# Build stage
FROM node:20 as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Copy yarn.lock if it exists
COPY yarn.lock* ./
# Install dependencies
RUN yarn install
# Copy the rest of the application
COPY . .
# Build Storybook
RUN yarn build-storybook
# Production stage
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
# Copy built Storybook files
COPY --from=builder /app/storybook-static .
# Create nginx configuration
RUN echo 'server { \
listen 6006; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
expires 30d; \
add_header Cache-Control "public, no-transform"; \
} \
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { \
expires 30d; \
add_header Cache-Control "public, no-transform"; \
} \
location ~ /\. { \
deny all; \
} \
error_page 404 /index.html; \
}' > /etc/nginx/conf.d/default.conf
# Set up permissions
RUN chown -R nginx:nginx /usr/share/nginx/html \
&& chown -R nginx:nginx /var/log/nginx
EXPOSE 6006

107
README.md
View File

@ -1,107 +0,0 @@
# Frontend Service
This directory contains the frontend service for ClassroomCopilot, including the web application and static file serving configuration.
## Directory Structure
```
frontend/
├── src/ # Frontend application source code
└── Dockerfile.dev # Development container configuration
└── Dockerfile.prod # Production container configuration
```
## Configuration
### Environment Variables
The frontend service uses the following environment variables:
- `VITE_FRONTEND_SITE_URL`: The base URL of the frontend application
- `VITE_APP_NAME`: The name of the application
- `VITE_SUPER_ADMIN_EMAIL`: Email address of the super admin
- `VITE_DEV`: Development mode flag
- `VITE_SUPABASE_URL`: Supabase API URL
- `VITE_SUPABASE_ANON_KEY`: Supabase anonymous key
- `VITE_STRICT_MODE`: Strict mode flag
- Other environment variables are defined in the root `.env` file
### Server Configuration
The frontend container uses a simple nginx configuration that:
- Serves static files on port 80
- Handles SPA routing
- Manages caching headers
- Denies access to hidden files
SSL termination and domain routing are handled by Nginx Proxy Manager (NPM).
## Usage
### Development
1. Start the development environment:
```bash
NGINX_MODE=dev ./init_macos_dev.sh up
```
2. Configure NPM:
- Create a new proxy host for app.localhost
- Forward to http://frontend:80
- Enable SSL with a self-signed certificate
- Add custom locations for SPA routing
3. Access the application:
- HTTPS: https://app.localhost
### Production
1. Set environment variables:
```bash
NGINX_MODE=prod
```
2. Start the production environment:
```bash
./init_macos_dev.sh up
```
3. Configure NPM:
- Create a new proxy host for app.classroomcopilot.ai
- Forward to http://frontend:80
- Enable SSL with Cloudflare certificates
- Add custom locations for SPA routing
## Security
- SSL termination handled by NPM
- Static file serving with proper caching headers
- Hidden file access denied
- SPA routing with fallback to index.html
## Troubleshooting
### Connection Issues
- Check NPM logs in the admin interface
- Verify frontend container is running
- Ensure NPM proxy host is properly configured
- Check network connectivity between NPM and frontend
### SPA Routing Issues
- Verify NPM custom locations are properly configured
- Check frontend container logs
- Ensure all routes fall back to index.html
## Maintenance
### Log Files
Located in `/var/log/nginx/`:
- `access.log`: General access logs
- `error.log`: Error logs
### Configuration Updates
1. Modify Dockerfile.dev or Dockerfile.prod as needed
2. Rebuild and restart the container:
```bash
docker compose up -d --build frontend
```

59
dist/.vite/manifest.json vendored Normal file
View File

@ -0,0 +1,59 @@
{
"_vendor-mui.js": {
"file": "assets/vendor-mui.js",
"name": "vendor-mui",
"imports": [
"_vendor-react.js"
]
},
"_vendor-react.js": {
"file": "assets/vendor-react.js",
"name": "vendor-react"
},
"_vendor-tldraw.js": {
"file": "assets/vendor-tldraw.js",
"name": "vendor-tldraw",
"imports": [
"_vendor-react.js",
"_vendor-mui.js"
]
},
"_vendor-utils.js": {
"file": "assets/vendor-utils.js",
"name": "vendor-utils",
"imports": [
"_vendor-react.js"
]
},
"index.html": {
"file": "assets/index-CmYeIoD0.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"imports": [
"_vendor-mui.js",
"_vendor-react.js",
"_vendor-tldraw.js",
"_vendor-utils.js"
],
"dynamicImports": [
"node_modules/pdfjs-dist/build/pdf.mjs"
],
"css": [
"assets/index.css"
],
"assets": [
"assets/pdf.worker.min.mjs"
]
},
"node_modules/pdfjs-dist/build/pdf.mjs": {
"file": "assets/pdf.js",
"name": "pdf",
"src": "node_modules/pdfjs-dist/build/pdf.mjs",
"isDynamicEntry": true
},
"node_modules/pdfjs-dist/build/pdf.worker.min.mjs": {
"file": "assets/pdf.worker.min.mjs",
"src": "node_modules/pdfjs-dist/build/pdf.worker.min.mjs"
}
}

68827
dist/assets/index-CmYeIoD0.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-CmYeIoD0.js.map vendored Normal file

File diff suppressed because one or more lines are too long

11178
dist/assets/index.css vendored Normal file

File diff suppressed because it is too large Load Diff

21232
dist/assets/pdf.js vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/assets/pdf.js.map vendored Normal file

File diff suppressed because one or more lines are too long

21
dist/assets/pdf.worker.min.mjs vendored Normal file

File diff suppressed because one or more lines are too long

19850
dist/assets/vendor-mui.js vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/assets/vendor-mui.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1901
dist/assets/vendor-react.js vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/assets/vendor-react.js.map vendored Normal file

File diff suppressed because one or more lines are too long

68796
dist/assets/vendor-tldraw.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/vendor-tldraw.js.map vendored Normal file

File diff suppressed because one or more lines are too long

12264
dist/assets/vendor-utils.js vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/assets/vendor-utils.js.map vendored Normal file

File diff suppressed because one or more lines are too long

12
dist/audioWorklet.js vendored Normal file
View File

@ -0,0 +1,12 @@
class AudioProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input.length > 0) {
const audioData = input[0];
this.port.postMessage(audioData);
}
return true;
}
}
registerProcessor('audio-processor', AudioProcessor);

BIN
dist/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
dist/icons/icon-192x192-maskable.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
dist/icons/icon-192x192.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
dist/icons/icon-512x512-maskable.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

BIN
dist/icons/icon-512x512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

21
dist/icons/sticker-tool.svg vendored Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Sticker outline -->
<path d="M20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Peeling corner effect -->
<path d="M16 8C16 10.2091 14.2091 12 12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Star decoration -->
<path d="M12 8L13 9L12 10L11 9L12 8Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

18
dist/index.html vendored Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Classroom Copilot</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<script type="module" crossorigin src="/assets/index-CmYeIoD0.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-mui.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-tldraw.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-utils.js">
<link rel="stylesheet" crossorigin href="/assets/index.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body>
<div id="root"></div>
</body>
</html>

1
dist/manifest.webmanifest vendored Normal file
View File

@ -0,0 +1 @@
{"name":"ClassroomCopilot","short_name":"CC","start_url":"/","display":"fullscreen","background_color":"#ffffff","theme_color":"#000000","lang":"en","scope":"/","icons":[{"src":"/icons/icon-192x192.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"/icons/icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"any"},{"src":"/icons/icon-192x192-maskable.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"/icons/icon-512x512-maskable.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]}

70
dist/offline.html vendored Normal file
View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Classroom Copilot</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f5f5f5;
color: #333;
text-align: center;
}
.container {
max-width: 600px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #1a73e8;
margin-bottom: 20px;
}
p {
line-height: 1.6;
margin-bottom: 20px;
}
.retry-button {
background-color: #1a73e8;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
.retry-button:hover {
background-color: #1557b0;
}
.icon {
font-size: 64px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📡</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Don't worry - any work you've done has been saved locally.</p>
<p>Please check your connection and try again.</p>
<button class="retry-button" onclick="window.location.reload()">Try Again</button>
</div>
<script>
// Check if we're back online
window.addEventListener('online', () => {
window.location.reload();
});
</script>
</body>
</html>

1
dist/registerSW.js vendored Normal file
View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}

3889
dist/sw.js vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/sw.js.map vendored Normal file

File diff suppressed because one or more lines are too long

16525
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@
"axios": "^1.7.7",
"cmdk": "^1.0.4",
"dotenv": "^16.4.5",
"p-limit": "^7.1.1",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"postcss-import": "^16.1.0",

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';
import { Routes, Route, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import { useUser } from './contexts/UserContext';
import { useNeoUser } from './contexts/NeoUserContext';
@ -22,19 +22,45 @@ import NotFound from './pages/user/NotFound';
import NotFoundPublic from './pages/NotFoundPublic';
import ShareHandler from './pages/tldraw/ShareHandler';
import SearxngPage from './pages/searxngPage';
import SimpleUploadTest from './pages/dev/SimpleUploadTest';
import { logger } from './debugConfig';
import { CircularProgress } from '@mui/material';
import { CCDocumentIntelligence } from './pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence';
import DashboardPage from './pages/user/dashboardPage';
const FullContextRoutes: React.FC = () => {
const { isInitialized: isUserInitialized } = useUser();
const { isLoading: isNeoUserLoading, isInitialized: isNeoUserInitialized } = useNeoUser();
const { isLoading: isNeoInstituteLoading, isInitialized: isNeoInstituteInitialized } = useNeoInstitute();
const isLoading =
!isUserInitialized ||
isNeoUserLoading ||
!isNeoUserInitialized ||
isNeoInstituteLoading ||
!isNeoInstituteInitialized;
if (isLoading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}
>
<CircularProgress />
</div>
);
}
return <Outlet />;
};
const AppRoutes: React.FC = () => {
const { user, loading: isAuthLoading } = useAuth();
const { isInitialized: isUserInitialized } =
useUser();
const { isLoading: isNeoUserLoading, isInitialized: isNeoUserInitialized } =
useNeoUser();
const {
isLoading: isNeoInstituteLoading,
isInitialized: isNeoInstituteInitialized,
} = useNeoInstitute();
const location = useLocation();
// Debug log for routing
@ -46,27 +72,10 @@ const AppRoutes: React.FC = () => {
authStatus: {
isLoading: isAuthLoading,
},
userStatus: {
isInitialized: isUserInitialized,
},
neoUserStatus: {
isLoading: isNeoUserLoading,
isInitialized: isNeoUserInitialized,
},
neoInstituteStatus: {
isLoading: isNeoInstituteLoading,
isInitialized: isNeoInstituteInitialized,
},
});
// Show loading state while initializing
if (
isAuthLoading ||
(user &&
(!isUserInitialized ||
!isNeoUserInitialized ||
!isNeoInstituteInitialized))
) {
if (isAuthLoading) {
return (
<Layout>
<div
@ -89,12 +98,18 @@ const AppRoutes: React.FC = () => {
{/* Public routes */}
<Route
path="/"
element={user ? <SinglePlayerPage /> : <TLDrawCanvas />}
element={user ? <DashboardPage /> : <TLDrawCanvas />}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/share" element={<ShareHandler />} />
{/* Lightweight authenticated routes */}
<Route
path="/dashboard"
element={user ? <DashboardPage /> : <Navigate to="/login" replace />}
/>
{/* Super Admin only routes */}
<Route
path="/admin"
@ -104,23 +119,22 @@ const AppRoutes: React.FC = () => {
/>
{/* Authentication only routes - only render if all contexts are initialized */}
{user &&
isUserInitialized &&
isNeoUserInitialized &&
isNeoInstituteInitialized && (
<>
<Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<CCExamMarker />} />
<Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
<Route path="/dev" element={<DevPage />} />
<Route path="/single-player" element={<SinglePlayerPage />} />
<Route path="/multiplayer" element={<MultiplayerUser />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</>
)}
{user && (
<Route element={<FullContextRoutes />}>
<Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<CCExamMarker />} />
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
<Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
<Route path="/dev" element={<DevPage />} />
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
<Route path="/single-player" element={<SinglePlayerPage />} />
<Route path="/multiplayer" element={<MultiplayerUser />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
)}
{/* Fallback route - use different NotFound pages based on auth state */}
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} />
@ -130,4 +144,3 @@ const AppRoutes: React.FC = () => {
};
export default AppRoutes;

View File

@ -2,8 +2,11 @@ import axios from 'axios';
import { logger } from './debugConfig';
// Use development backend URL if no custom URL is provided
const appProtocol = import.meta.env.VITE_APP_PROTOCOL;
const baseURL = `${appProtocol}://${import.meta.env.VITE_APP_API_URL}`;
const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
if (!import.meta.env.VITE_API_URL) {
logger.warn('axios', '⚠️ VITE_API_URL not set, defaulting to http://localhost:8080');
}
const instance = axios.create({
baseURL,

View File

@ -0,0 +1 @@

View File

@ -1,8 +1,11 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Session, User } from '@supabase/supabase-js';
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { storageService, StorageKeys } from '../services/auth/localStorageService';
export interface AuthContextType {
user: CCUser | null;
@ -28,64 +31,205 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [user, setUser] = useState<CCUser | null>(null);
const [user_role, setUserRole] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const loadUser = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
const persistSession = useCallback((session: Session | null) => {
if (session) {
storageService.set(StorageKeys.SUPABASE_SESSION, session);
} else {
storageService.remove(StorageKeys.SUPABASE_SESSION);
}
}, []);
if (user) {
const metadata = user.user_metadata as CCUserMetadata;
setUser({
id: user.id,
email: user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
school_db_name: 'cc.institutes.development.default',
created_at: user.created_at,
updated_at: user.updated_at
});
setUserRole(metadata.user_role || null);
} else {
setUser(null);
}
} catch (error) {
logger.error('auth-context', '❌ Failed to load user', { error });
setError(error instanceof Error ? error : new Error('Failed to load user'));
} finally {
setLoading(false);
}
};
const restoreSessionFromStorage = useCallback(async (): Promise<Session | null> => {
const persistedSession = storageService.get(StorageKeys.SUPABASE_SESSION);
loadUser();
if (!persistedSession) {
return null;
}
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' && session?.user) {
const metadata = session.user.user_metadata as CCUserMetadata;
setUser({
id: session.user.id,
email: session.user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
school_db_name: 'cc.institutes.development.default',
created_at: session.user.created_at,
updated_at: session.user.updated_at
});
} else if (event === 'SIGNED_OUT') {
setUser(null);
}
if (!persistedSession.access_token || !persistedSession.refresh_token) {
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}
const { data: restored, error: restoreError } = await supabase.auth.setSession({
access_token: persistedSession.access_token,
refresh_token: persistedSession.refresh_token,
});
if (restoreError) {
logger.warn('auth-context', '⚠️ Failed to restore persisted Supabase session', {
error: restoreError.message ?? restoreError,
});
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}
if (restored.session) {
persistSession(restored.session);
return restored.session;
}
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}, [persistSession]);
const buildUserFromSupabase = useCallback(async (supabaseUser: User | null): Promise<{ user: CCUser | null; role: string | null }> => {
if (!supabaseUser) {
return { user: null, role: null };
}
const metadata = supabaseUser.user_metadata as CCUserMetadata;
const baseUsername = metadata.username || metadata.preferred_username || metadata.email?.split('@')[0] || supabaseUser.email?.split('@')[0] || 'user';
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
const userType = (metadata.user_type || 'email_teacher').trim();
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(userType || 'standard', supabaseUser.id);
const schoolDbName = storedSchoolDb || '';
const resolvedUser: CCUser = {
id: supabaseUser.id,
email: supabaseUser.email,
user_type: userType,
username: baseUsername,
display_name: baseDisplayName,
user_db_name: userDbName,
school_db_name: schoolDbName,
created_at: supabaseUser.created_at,
updated_at: supabaseUser.updated_at
};
const resolvedRole = metadata.user_role || userType || null;
return { user: resolvedUser, role: resolvedRole };
}, []);
useEffect(() => {
let isMounted = true;
const loadInitialSession = async () => {
setLoading(true);
try {
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
throw error;
}
let activeSession: Session | null = session ?? null;
if (!activeSession) {
activeSession = await restoreSessionFromStorage();
}
if (!isMounted) {
return;
}
if (activeSession?.user) {
persistSession(activeSession);
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(activeSession.user);
if (!isMounted) {
return;
}
setUser(resolvedUser);
setUserRole(role);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from initial session', {
error: buildError,
});
if (!isMounted) {
return;
}
setUser(null);
setUserRole(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
persistSession(null);
setUser(null);
setUserRole(null);
}
} catch (error) {
logger.error('auth-context', '❌ Failed to load initial session', { error });
if (isMounted) {
setError(error instanceof Error ? error : new Error('Failed to load user'));
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadInitialSession();
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (!isMounted) {
return;
}
switch (event) {
case 'SIGNED_IN':
case 'TOKEN_REFRESHED':
case 'INITIAL_SESSION': {
persistSession(session ?? null);
if (session?.user) {
setLoading(true);
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
if (!isMounted) {
return;
}
setUser(resolvedUser);
setUserRole(role);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', {
event,
error: buildError,
});
setUser(null);
setUserRole(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
} finally {
if (isMounted) {
setLoading(false);
}
}
} else {
setUser(null);
setUserRole(null);
if (isMounted) {
setLoading(false);
}
}
break;
}
case 'SIGNED_OUT': {
persistSession(null);
setUser(null);
setUserRole(null);
if (isMounted) {
setLoading(false);
}
break;
}
default:
break;
}
}
);
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, []);
}, [buildUserFromSupabase, persistSession, restoreSessionFromStorage]);
const signIn = async (email: string, password: string) => {
try {
@ -97,19 +241,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (signInError) throw signInError;
if (data.session) {
persistSession(data.session);
}
if (data.user) {
const metadata = data.user.user_metadata as CCUserMetadata;
setUser({
id: data.user.id,
email: data.user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
school_db_name: 'cc.institutes.development.default',
created_at: data.user.created_at,
updated_at: data.user.updated_at
});
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
setUser(resolvedUser);
setUserRole(role);
}
} catch (error) {
logger.error('auth-context', '❌ Sign in failed', { error });
@ -124,6 +263,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
setLoading(true);
await authService.logout();
persistSession(null);
setUser(null);
navigate('/');
} catch (error) {

View File

@ -29,6 +29,13 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
const [error, setError] = useState<string | null>(null);
useEffect(() => {
logger.debug('neo-institute-context', '🔄 useEffect triggered', {
isUserInitialized,
hasProfile: !!profile,
hasUser: !!user,
isInitialized
});
// Wait for user profile to be ready
if (!isUserInitialized) {
logger.debug('neo-institute-context', '⏳ Waiting for user initialization...');
@ -39,6 +46,7 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
if (!profile || !profile.school_db_name) {
setIsLoading(false);
setIsInitialized(true);
logger.debug('neo-institute-context', ' No school database; marking institute context ready');
return;
}
@ -54,7 +62,7 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
if (node) {
setSchoolNode(node);
logger.debug('neo-institute-context', '✅ School node loaded', {
schoolId: node.unique_id,
schoolId: node.uuid_string,
dbName: profile.school_db_name
});
} else {
@ -68,14 +76,15 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
schoolDbName: profile.school_db_name
});
setError(errorMessage);
} finally {
setIsLoading(false);
setIsInitialized(true);
}
};
} finally {
setIsLoading(false);
setIsInitialized(true);
logger.debug('neo-institute-context', '✅ Institute context initialization complete');
}
};
loadSchoolNode();
}, [user?.email, profile, isUserInitialized]);
}, [user, profile, isUserInitialized, isInitialized]);
return (
<NeoInstituteContext.Provider value={{

View File

@ -3,6 +3,7 @@ import { useAuth } from './AuthContext';
import { useUser } from './UserContext';
import { logger } from '../debugConfig';
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { CalendarStructure, WorkerStructure } from '../types/navigation';
import { useNavigationStore } from '../stores/navigationStore';
@ -11,7 +12,7 @@ export interface CalendarNode {
id: string;
label: string;
title: string;
tldraw_snapshot: string;
node_storage_path: string;
type?: CCCalendarNodeProps['__primarylabel__'];
nodeData?: CCCalendarNodeProps;
}
@ -20,7 +21,7 @@ export interface WorkerNode {
id: string;
label: string;
title: string;
tldraw_snapshot: string;
node_storage_path: string;
type?: CCUserTeacherTimetableNodeProps['__primarylabel__'];
nodeData?: CCUserTeacherTimetableNodeProps;
}
@ -162,8 +163,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
backgroundColor: '#ffffff',
isLocked: false,
__primarylabel__: 'UserTeacherTimetable',
unique_id: '',
tldraw_snapshot: '',
uuid_string: '',
node_storage_path: '',
created: new Date().toISOString(),
merged: new Date().toISOString(),
state: {
@ -177,7 +178,34 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Initialize context when dependencies are ready
useEffect(() => {
if (!isUserInitialized || !profile || isInitialized || initializationRef.current.hasStarted) {
logger.debug('neo-user-context', '🔄 useEffect triggered', {
isUserInitialized,
hasProfile: !!profile,
hasUser: !!user,
isInitialized,
hasStarted: initializationRef.current.hasStarted
});
if (!isUserInitialized) {
logger.debug('neo-user-context', '⏳ Waiting for user context initialization');
return;
}
if (!profile) {
if (!initializationRef.current.isComplete) {
setIsLoading(false);
setIsInitialized(true);
initializationRef.current.isComplete = true;
logger.debug('neo-user-context', ' No profile available; marking context initialized');
}
return;
}
if (isInitialized || initializationRef.current.hasStarted) {
logger.debug('neo-user-context', ' Initialization already in progress or complete', {
isInitialized,
hasStarted: initializationRef.current.hasStarted
});
return;
}
@ -189,7 +217,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Set database names
const userDb = profile.user_db_name || (user?.email ?
`cc.users.${user.email.replace('@', 'at').replace(/\./g, 'dot')}` : null);
DatabaseNameService.getStoredUserDatabase() || null : null);
if (!userDb) {
throw new Error('No user database name available');
@ -199,27 +227,42 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
logger.debug('neo-user-context', '🔄 Starting context initialization');
// Initialize user node
await navigationStore.switchContext({
main: 'profile',
base: 'profile',
extended: 'overview'
}, userDb, profile.school_db_name);
try {
await navigationStore.switchContext({
main: 'profile',
base: 'profile',
extended: 'overview'
}, userDb, profile.school_db_name);
const userNavigationNode = navigationStore.context.node;
if (userNavigationNode?.data) {
const userNodeData: CCUserNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'User',
unique_id: userNavigationNode.id,
tldraw_snapshot: userNavigationNode.tldraw_snapshot || '',
title: String(userNavigationNode.data?.user_name || 'User'),
user_name: String(userNavigationNode.data?.user_name || 'User'),
user_email: user?.email || '',
user_type: 'User',
user_id: userNavigationNode.id,
worker_node_data: JSON.stringify(userNavigationNode.data || {})
};
setUserNode(userNodeData);
const userNavigationNode = navigationStore.context.node;
if (userNavigationNode?.id && userNavigationNode?.data) {
const userNodeData: CCUserNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'User',
uuid_string: userNavigationNode.id,
node_storage_path: userNavigationNode.node_storage_path || '',
title: String(userNavigationNode.data?.user_name || 'User'),
user_name: String(userNavigationNode.data?.user_name || 'User'),
user_email: user?.email || '',
user_type: 'User',
user_id: userNavigationNode.id,
worker_node_data: JSON.stringify(userNavigationNode.data || {})
};
setUserNode(userNodeData);
logger.debug('neo-user-context', '✅ User node loaded from navigation store');
} else if (userNavigationNode?.id) {
logger.debug('neo-user-context', ' User node exists but data not yet loaded - will retry later', {
nodeId: userNavigationNode.id,
hasData: !!userNavigationNode.data
});
} else {
logger.debug('neo-user-context', ' No user node in navigation store yet - will retry later');
}
} catch (navError) {
logger.warn('neo-user-context', '⚠️ Navigation store initialization failed - continuing without user node', {
error: navError instanceof Error ? navError.message : String(navError)
});
// Continue without user node - this is not critical for basic functionality
}
// Set final state
@ -241,7 +284,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
initializeContext();
}, [user?.email, profile, isUserInitialized, navigationStore, isInitialized]);
}, [user, profile, isUserInitialized, navigationStore, isInitialized]);
// Calendar Navigation Functions
const navigateToDay = async (id: string) => {
@ -258,8 +301,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarDay',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'day',
@ -272,7 +315,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'CalendarDay',
nodeData
});
@ -298,8 +341,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarWeek',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'week',
@ -312,7 +355,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'CalendarWeek',
nodeData
});
@ -338,8 +381,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarMonth',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'month',
@ -352,7 +395,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'CalendarMonth',
nodeData
});
@ -378,8 +421,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarYear',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'year',
@ -392,7 +435,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'CalendarYear',
nodeData
});
@ -419,8 +462,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
@ -430,7 +473,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
@ -456,8 +499,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
@ -467,7 +510,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
@ -493,8 +536,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: id || node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
@ -504,7 +547,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: id || node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
@ -531,8 +574,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: node.id
@ -542,7 +585,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
@ -569,8 +612,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot || '',
uuid_string: node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: node.id
@ -580,7 +623,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
id: node.id,
label: node.label,
title: node.label,
tldraw_snapshot: node.tldraw_snapshot || '',
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});

View File

@ -1,9 +1,12 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { supabase } from '../supabaseClient';
import { logger } from '../debugConfig';
import { CCUser, CCUserMetadata } from '../services/auth/authService';
import { UserPreferences } from '../services/auth/profileService';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { provisionUser } from '../services/provisioningService';
import { storageService, StorageKeys } from '../services/auth/localStorageService';
export interface UserContextType {
user: CCUser | null;
@ -31,7 +34,7 @@ export const UserContext = createContext<UserContextType>({
clearError: () => {}
});
export function UserProvider({ children }: { children: React.ReactNode }) {
export const UserProvider = ({ children }: { children: React.ReactNode }) => {
const [user] = useState<CCUser | null>(null);
const [profile, setProfile] = useState<CCUser | null>(null);
const [preferences, setPreferences] = useState<UserPreferences>({});
@ -39,72 +42,370 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isMobile] = useState(window.innerWidth <= 768);
const mountedRef = React.useRef(true);
useEffect(() => {
const loadUserProfile = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
// Use the main Supabase client for all operations to ensure proper session persistence
// This avoids the "Multiple GoTrueClient instances" warning and ensures session restoration works
if (!user) {
setProfile(null);
setLoading(false);
setIsInitialized(true);
const resolveProfile = useCallback(async (supabaseUser?: User | null, session?: Session | null) => {
// Prevent duplicate work when we already have the same user resolved
if (mountedRef.current && isInitialized) {
const resolvedUserId = profile?.id;
const incomingUserId = supabaseUser?.id ?? session?.user?.id ?? null;
if (!incomingUserId && !supabaseUser) {
logger.debug('user-context', '⚠️ Profile already initialized for guest session, skipping resolution');
return;
}
if (incomingUserId && resolvedUserId && resolvedUserId === incomingUserId) {
logger.debug('user-context', '⚠️ Profile already initialized for current user, skipping resolution');
return;
}
}
let userInfo: User | null = null; // Declare at function scope
try {
logger.debug('user-context', '🔄 Resolving user profile', {
hasSupabaseUser: !!supabaseUser,
isInitialized
});
logger.debug('user-context', '🔧 Step 1: Starting profile resolution...');
// Don't set loading to true immediately - let the UI show progress naturally
let authSession = session;
userInfo = supabaseUser ?? null;
logger.debug('user-context', '🔧 Step 2: Getting auth session...');
if (!authSession) {
const { data } = await supabase.auth.getSession();
authSession = data.session;
logger.debug('user-context', '🔧 Step 2a: Got session from supabase', {
hasSession: !!authSession
});
}
logger.debug('user-context', '🔧 Step 3: Getting user info...');
if (!userInfo) {
const { data } = await supabase.auth.getUser();
userInfo = data.user;
logger.debug('user-context', '🔧 Step 3a: Got user from supabase', {
hasUser: !!userInfo
});
}
if (!userInfo) {
logger.debug('user-context', '⚠️ No user info available - clearing profile');
if (!mountedRef.current) {
return;
}
setProfile(null);
setPreferences({});
setError(null);
setLoading(false);
return;
}
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
logger.debug('user-context', '🔧 Step 4: User info available, proceeding...', {
userId: userInfo.id,
email: userInfo.email
});
if (error) {
throw error;
let profileRow: Record<string, unknown> | null = null;
logger.debug('user-context', '🔧 Step 5: Querying profiles table...', {
userId: userInfo.id
});
// Set loading state when we start the actual database query
setLoading(true);
// Query profiles table without timeout to see actual error
logger.debug('user-context', '🔧 Step 5b: Starting profiles query...', {
userId: userInfo.id,
clientType: 'authenticated'
});
// Try direct fetch instead of Supabase client to bypass hanging issue
logger.debug('user-context', '🔧 Step 5b1: About to make profiles query with direct fetch...', {
userId: userInfo.id,
queryStarted: true
});
const { data, error } = await fetch(`http://localhost:8000/rest/v1/profiles?select=*&id=eq.${userInfo.id}`, {
headers: {
'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0`,
'Content-Type': 'application/json'
}
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
return { data: result[0] || null, error: null };
})
.catch((err) => {
logger.debug('user-context', '🔧 Step 5b1: Direct fetch failed', {
userId: userInfo?.id,
error: err.message
});
return { data: null, error: { message: err.message, code: 'FETCH_ERROR' } };
});
const metadata = user.user_metadata as CCUserMetadata;
const userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', metadata.username || '');
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
logger.debug('user-context', '🔧 Step 5b2: Direct fetch completed...', {
userId: userInfo.id,
hasData: !!data,
hasError: !!error
});
const userProfile: CCUser = {
id: user.id,
email: user.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: metadata.display_name || '',
user_db_name: userDbName,
school_db_name: schoolDbName,
created_at: user.created_at,
updated_at: user.updated_at
logger.debug('user-context', '🔧 Step 5c: Profiles query completed', {
hasData: !!data,
hasError: !!error,
errorCode: error?.code,
errorMessage: error?.message
});
logger.debug('user-context', '🔧 Step 5a: Profiles query result', {
hasData: !!data,
hasError: !!error,
errorCode: error?.code,
errorMessage: error?.message
});
if (error && error.code !== 'PGRST116') {
logger.warn('user-context', '⚠️ Profiles query failed, using fallback', {
error: error.message,
code: error.code
});
// Don't throw error, just use fallback profile
profileRow = null;
} else if (data) {
profileRow = data;
logger.debug('user-context', '✅ Found profile data in database', {
userId: data.id,
userType: data.user_type,
userDbName: data.user_db_name
});
} else {
logger.debug('user-context', '⚠️ No profile data found - will create default');
profileRow = null;
}
// Clear loading state after profiles query completes
setLoading(false);
logger.debug('user-context', '🔧 Step 5d: Loading state cleared');
logger.debug('user-context', '🔧 Step 6: Processing profile data...', {
userId: userInfo.id,
hasProfileRow: !!profileRow,
hasUserDb: !!profileRow?.user_db_name,
hasSchoolDb: !!profileRow?.school_db_name
});
const metadata = userInfo.user_metadata as CCUserMetadata;
logger.debug('user-context', '🔧 Step 7: Processing user metadata...', {
hasMetadata: !!metadata,
userType: metadata?.user_type
});
let userDbName = profileRow?.user_db_name ?? null;
let schoolDbName = profileRow?.school_db_name ?? null;
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
// Start provisioning in background (non-blocking)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const provisioningPromise = provisionUser(userInfo.id, authSession?.access_token ?? null)
.then(provisioned => {
if (provisioned) {
logger.debug('user-context', '✅ Provisioning completed in background', {
userDbName: provisioned.user_db_name,
workerDbName: provisioned.worker_db_name
});
// Update localStorage with provisioned values
DatabaseNameService.rememberDatabaseNames({
userDbName: provisioned.user_db_name,
schoolDbName: provisioned.worker_db_name || ''
});
}
})
.catch(provisionError => {
logger.warn('user-context', '⚠️ Background provisioning failed', {
userId: userInfo?.id,
provisionError: provisionError instanceof Error ? provisionError.message : String(provisionError)
});
});
if (!userDbName && storedUserDb) {
userDbName = storedUserDb;
}
if (!schoolDbName && storedSchoolDb) {
schoolDbName = storedSchoolDb;
}
logger.debug('user-context', ' Database name resolution', {
userDbName,
schoolDbName
});
if (!userDbName) {
userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', userInfo.id);
}
if (!schoolDbName) {
schoolDbName = '';
}
DatabaseNameService.rememberDatabaseNames({
userDbName: String(userDbName || ''),
schoolDbName: String(schoolDbName || '')
});
logger.debug('user-context', '🔧 Creating user profile object...', {
userId: userInfo.id,
userDbName,
schoolDbName,
userType: metadata.user_type
});
const userProfile: CCUser = {
id: userInfo.id,
email: userInfo.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: String(metadata.display_name || ''),
user_db_name: String(userDbName || ''),
school_db_name: String(schoolDbName || ''),
created_at: userInfo.created_at,
updated_at: userInfo.updated_at
};
if (!mountedRef.current) {
logger.debug('user-context', '❌ Component unmounted during profile creation');
return;
}
logger.debug('user-context', '🔧 Setting profile and preferences...', {
profileId: userProfile.id
});
setProfile(userProfile);
setPreferences({
theme: (profileRow?.theme && typeof profileRow.theme === 'string' && ['system', 'light', 'dark'].includes(profileRow.theme)) ? profileRow.theme as 'system' | 'light' | 'dark' : 'system',
notifications: Boolean(profileRow?.notifications_enabled)
});
logger.debug('user-context', '✅ User profile loaded', {
userId: userProfile.id,
userType: userProfile.user_type,
username: userProfile.username,
userDbName: userProfile.user_db_name,
schoolDbName: userProfile.school_db_name
});
setError(null);
} catch (error) {
logger.error('user-context', '❌ Failed to load user profile', { error });
if (!mountedRef.current) {
return;
}
logger.error('user-context', '❌ Resolving user profile failed', {
message: error instanceof Error ? error.message : String(error)
});
// Create fallback profile even when errors occur
logger.debug('user-context', '🔧 Creating fallback profile due to error...', {
userId: userInfo?.id,
email: userInfo?.email
});
if (userInfo) {
const metadata = userInfo.user_metadata as CCUserMetadata;
const fallbackProfile: CCUser = {
id: userInfo.id,
email: userInfo.email,
user_type: metadata?.user_type || 'email_teacher',
username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id),
school_db_name: '',
created_at: userInfo.created_at,
updated_at: userInfo.updated_at
};
setProfile(userProfile);
logger.debug('user-context', '✅ User profile loaded', {
userId: userProfile.id,
userType: userProfile.user_type,
username: userProfile.username,
userDbName: userProfile.user_db_name,
schoolDbName: userProfile.school_db_name
DatabaseNameService.rememberDatabaseNames({
userDbName: fallbackProfile.user_db_name,
schoolDbName: fallbackProfile.school_db_name
});
// Load preferences from profile data
setPreferences({
theme: data.theme || 'system',
notifications: data.notifications_enabled || false
setProfile(fallbackProfile);
logger.debug('user-context', '✅ Fallback profile created', {
userId: fallbackProfile.id,
userType: fallbackProfile.user_type,
userDbName: fallbackProfile.user_db_name
});
} catch (error) {
logger.error('user-context', '❌ Failed to load user profile', { error });
setError(error instanceof Error ? error : new Error('Failed to load user profile'));
} finally {
setLoading(false);
setIsInitialized(true);
} else {
setProfile(null);
}
};
loadUserProfile();
}, []);
setPreferences({});
setError(error instanceof Error ? error : new Error('Failed to load user profile'));
setLoading(false); // Ensure loading is cleared on error
} finally {
logger.debug('user-context', '🔧 Finalizing user context initialization...', {
isMounted: mountedRef.current
});
if (mountedRef.current) {
// Loading state is already managed above, just log completion
logger.debug('user-context', '✅ User context initialization complete');
}
logger.debug('user-context', '🔧 Step 10: Setting isInitialized to true');
setIsInitialized(true);
logger.debug('user-context', '✅ User context initialized flag set - initialization complete!', {
isInitialized: true,
profileId: profile?.id,
userType: profile?.user_type
});
}
}, [profile?.id, profile?.user_type, isInitialized]);
useEffect(() => {
mountedRef.current = true;
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
if (!mountedRef.current) {
return;
}
logger.debug('user-context', '🔄 Auth state change', {
event,
hasSession: !!session,
hasUser: !!session?.user
});
switch (event) {
case 'SIGNED_OUT':
setLoading(false);
setProfile(null);
setPreferences({});
setIsInitialized(true);
setError(null);
break;
case 'SIGNED_IN':
case 'TOKEN_REFRESHED':
case 'INITIAL_SESSION':
await resolveProfile(session?.user ?? null, session ?? null);
break;
default:
break;
}
});
return () => {
mountedRef.current = false;
subscription.unsubscribe();
};
}, [resolveProfile]);
const updateProfile = async (updates: Partial<CCUser>) => {
if (!user?.id || !profile) {
@ -168,6 +469,20 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
}
};
// Store profile in localStorage whenever it changes
useEffect(() => {
if (profile) {
storageService.set(StorageKeys.USER, profile);
logger.debug('user-context', '💾 Stored user profile in localStorage', {
userId: profile.id,
userType: profile.user_type
});
} else {
storageService.remove(StorageKeys.USER);
logger.debug('user-context', '🗑️ Removed user profile from localStorage');
}
}, [profile]);
return (
<UserContext.Provider
value={{

View File

@ -24,6 +24,7 @@ export type LogCategory =
| 'auth-service'
| 'graph-service'
| 'registration-service'
| 'provisioning-service'
| 'snapshot-service'
| 'shared-store-service'
| 'sync-service'
@ -281,6 +282,7 @@ logger.setConfig({
'single-player-page',
'user-toolbar',
'registration-service',
'provisioning-service',
'graph-service',
'graph-shape',
'calendar-shape',

View File

@ -28,7 +28,8 @@ import {
AssignmentTurnedIn as ExamMarkerIcon,
Settings as SettingsIcon,
Search as SearchIcon,
AdminPanelSettings as AdminIcon
AdminPanelSettings as AdminIcon,
Home as HomeIcon
} from '@mui/icons-material';
import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
@ -125,7 +126,7 @@ const Header: React.FC = () => {
},
fontSize: { xs: '1rem', sm: '1.25rem' }
}}
onClick={() => navigate(isAuthenticated ? '/single-player' : '/')}
onClick={() => navigate(isAuthenticated ? '/dashboard' : '/')}
>
ClassroomCopilot
</Typography>
@ -192,6 +193,13 @@ const Header: React.FC = () => {
}}
>
{isAuthenticated ? [
<MenuItem key="dashboard" onClick={() => handleNavigation('/dashboard')}>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
<ListItemText primary="Dashboard" />
</MenuItem>,
<Divider key="dashboard-divider" />,
// Development Tools Section
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
<ListItemIcon>

View File

@ -37,8 +37,8 @@ export default function AdminDashboard() {
});
const handleReturn = () => {
logger.info('admin-page', '🏠 Returning to single player page');
navigate('/single-player');
logger.info('admin-page', '🏠 Returning to dashboard');
navigate('/dashboard');
};
if (!isSuperAdmin) {

View File

@ -17,7 +17,7 @@ const LoginPage: React.FC = () => {
useEffect(() => {
if (user) {
navigate('/single-player');
navigate('/dashboard');
}
}, [user, navigate]);
@ -25,7 +25,7 @@ const LoginPage: React.FC = () => {
try {
setError(null);
await signIn(credentials.email, credentials.password);
navigate('/single-player');
navigate('/dashboard');
} catch (error) {
logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed');

View File

@ -32,7 +32,7 @@ const SignupPage: React.FC = () => {
useEffect(() => {
if (user) {
navigate('/single-player');
navigate('/dashboard');
}
}, [user, navigate]);
@ -46,7 +46,7 @@ const SignupPage: React.FC = () => {
displayName
);
if (result.user) {
navigate('/single-player');
navigate('/dashboard');
}
} catch (error) {
logger.error('signup-page', '❌ Registration failed', error);
@ -117,4 +117,3 @@ const SignupPage: React.FC = () => {
};
export default SignupPage;

View File

@ -0,0 +1,876 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
CardHeader,
Grid,
Alert,
Chip,
LinearProgress,
List,
ListItem,
ListItemText,
ListItemIcon,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Divider,
Select,
MenuItem,
FormControl,
InputLabel,
Paper,
TextField,
Pagination,
Stack
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Folder as FolderIcon,
FolderOpen as FolderOpenIcon,
Description as FileIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
PlayArrow as ProcessIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon
} from '@mui/icons-material';
import { supabase } from '../../supabaseClient';
import {
pickDirectory,
processDirectoryFiles,
calculateDirectoryStats,
formatFileSize,
isDirectoryPickerSupported,
FileWithPath
} from '../../utils/folderPicker';
interface Cabinet {
id: string;
name: string;
}
interface FileRecord {
id: string;
name: string;
mime_type?: string;
is_directory?: boolean;
size_bytes?: number;
processing_status?: string;
relative_path?: string;
created_at?: string;
}
interface PaginationInfo {
page: number;
per_page: number;
total_count: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
offset: number;
}
interface FileListResponse {
files: FileRecord[];
pagination: PaginationInfo;
filters: {
search?: string;
sort_by: string;
sort_order: string;
include_directories: boolean;
parent_directory_id?: string;
};
}
interface UploadProgress {
path: string;
size: number;
status: 'queued' | 'uploading' | 'done' | 'error';
progress: number;
error?: string;
}
const SimpleUploadTest: React.FC = () => {
// State management
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
const [files, setFiles] = useState<FileRecord[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [loading, setLoading] = useState(false);
// Pagination and filtering state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
const [directoryStats, setDirectoryStats] = useState<any>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info', text: string } | null>(null);
const [uploadType, setUploadType] = useState<'old' | 'new'>('new');
// Refs
const fileInputRef = useRef<HTMLInputElement>(null);
const dirInputRef = useRef<HTMLInputElement>(null);
const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080';
const apiFetch = useCallback(async (url: string, init?: { method?: string; body?: FormData | string; headers?: Record<string, string> }) => {
const session = await supabase.auth.getSession();
const token = session?.data?.session?.access_token;
if (!token) {
throw new Error('No authentication token available');
}
const headers = {
'Authorization': `Bearer ${token}`,
...((init?.headers as Record<string, string>) || {})
};
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const res = await fetch(fullUrl, { ...init, headers });
if (!res.ok) {
const errorText = await res.text();
throw new Error(`HTTP ${res.status}: ${errorText}`);
}
return res.json();
}, [API_BASE]);
// Load cabinets and files
const loadCabinets = useCallback(async () => {
setLoading(true);
try {
const data = await apiFetch('/database/cabinets');
const all = [...(data.owned || []), ...(data.shared || [])];
setCabinets(all);
if (all.length && !selectedCabinet) {
setSelectedCabinet(all[0].id);
}
setMessage({ type: 'success', text: `Loaded ${all.length} cabinets` });
} catch (error: unknown) {
console.error('Failed to load cabinets:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Failed to load cabinets: ${errorMessage}` });
} finally {
setLoading(false);
}
}, [selectedCabinet, apiFetch]);
const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => {
if (!cabinetId) return;
setLoading(true);
try {
// Build query parameters for pagination, search, and sorting
const params = new URLSearchParams({
cabinet_id: cabinetId,
page: page.toString(),
per_page: itemsPerPage.toString(),
sort_by: sortBy,
sort_order: sortOrder,
include_directories: 'true'
});
if (searchTerm) {
params.append('search', searchTerm);
}
// Use the new simple upload endpoint for listing files with pagination
const data: FileListResponse = await apiFetch(`/simple-upload/files?${params.toString()}`);
setFiles(data.files || []);
setPagination(data.pagination);
setMessage({
type: 'success',
text: `Loaded ${data.files?.length || 0} files (${data.pagination.total_count} total)`
});
} catch (error: unknown) {
console.error('Failed to load files:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Failed to load files: ${errorMessage}` });
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch]);
useEffect(() => {
loadCabinets();
}, [loadCabinets]);
useEffect(() => {
if (selectedCabinet) {
setCurrentPage(1); // Reset to first page when cabinet changes
loadFiles(selectedCabinet, 1);
}
}, [selectedCabinet, loadFiles]);
// Reload files when pagination/filtering parameters change
useEffect(() => {
if (selectedCabinet) {
loadFiles(selectedCabinet, currentPage);
}
}, [selectedCabinet, loadFiles, currentPage]);
// Search with debouncing
useEffect(() => {
if (selectedCabinet) {
const timeoutId = setTimeout(() => {
setCurrentPage(1); // Reset to first page when searching
loadFiles(selectedCabinet, 1);
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}
}, [searchTerm, selectedCabinet, loadFiles]);
// Single file upload
const handleSingleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || !selectedCabinet) return;
const file = e.target.files[0];
const formData = new FormData();
formData.append('cabinet_id', selectedCabinet);
formData.append('path', file.name);
formData.append('scope', 'teacher');
formData.append('file', file);
try {
setLoading(true);
// Choose endpoint based on upload type
const endpoint = uploadType === 'new' ? '/simple-upload/files/upload' : '/database/files/upload';
const result = await apiFetch(endpoint, {
method: 'POST',
body: formData
});
console.log('Upload result:', result);
setMessage({
type: 'success',
text: `File uploaded successfully using ${uploadType === 'new' ? 'NEW' : 'OLD'} endpoint: ${file.name}`
});
await loadFiles(selectedCabinet);
e.target.value = '';
} catch (error: unknown) {
console.error('Upload failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Upload failed: ${errorMessage}` });
} finally {
setLoading(false);
}
};
// Directory upload handling
const handleDirectoryPicker = async () => {
try {
const files = await pickDirectory();
prepareDirectoryUpload(files);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage === 'fallback-input') {
dirInputRef.current?.click();
} else if (errorMessage === 'user-cancelled') {
setMessage({ type: 'info', text: 'Directory selection cancelled' });
} else {
console.error('Directory picker error:', error);
setMessage({ type: 'error', text: 'Failed to pick directory. Trying fallback method...' });
dirInputRef.current?.click();
}
}
};
const handleFallbackDirectorySelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const files = processDirectoryFiles(e.target.files);
prepareDirectoryUpload(files);
e.target.value = '';
};
const prepareDirectoryUpload = (files: FileWithPath[]) => {
if (files.length === 0) {
setMessage({ type: 'error', text: 'No files selected' });
return;
}
setSelectedFiles(files);
setDirectoryStats(calculateDirectoryStats(files));
const progress: UploadProgress[] = files.map(file => ({
path: file.relativePath,
size: file.size,
status: 'queued',
progress: 0
}));
setUploadProgress(progress);
setShowUploadDialog(true);
};
const startDirectoryUpload = async () => {
if (!selectedCabinet || selectedFiles.length === 0) return;
setIsUploading(true);
try {
const firstFilePath = selectedFiles[0].relativePath;
const directoryName = firstFilePath.split('/')[0] || 'uploaded-folder';
const formData = new FormData();
formData.append('cabinet_id', selectedCabinet);
formData.append('scope', 'teacher');
formData.append('directory_name', directoryName);
selectedFiles.forEach(file => {
formData.append('files', file);
});
const relativePaths = selectedFiles.map(f => f.relativePath);
formData.append('file_paths', JSON.stringify(relativePaths));
const result = await apiFetch('/simple-upload/files/upload-directory', {
method: 'POST',
body: formData
});
console.log('Directory upload result:', result);
setUploadProgress(prev => prev.map(item => ({
...item,
status: 'done',
progress: 100
})));
setMessage({
type: 'success',
text: `Directory uploaded successfully: ${directoryName} (${selectedFiles.length} files)`
});
await loadFiles(selectedCabinet);
setTimeout(() => {
setShowUploadDialog(false);
setIsUploading(false);
setSelectedFiles([]);
setUploadProgress([]);
}, 2000);
} catch (error: unknown) {
console.error('Directory upload failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Directory upload failed: ${errorMessage}` });
setUploadProgress(prev => prev.map(item => ({
...item,
status: 'error',
error: String(error)
})));
setIsUploading(false);
}
};
// Delete file
const handleDelete = async (fileId: string) => {
try {
await apiFetch(`/simple-upload/files/${fileId}`, { method: 'DELETE' });
setMessage({ type: 'success', text: 'File deleted successfully' });
await loadFiles(selectedCabinet);
} catch (error: unknown) {
console.error('Delete failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Delete failed: ${errorMessage}` });
}
};
// Manual processing trigger
const handleManualProcessing = async (fileId: string) => {
try {
const formData = new FormData();
formData.append('processing_type', 'basic');
const result = await apiFetch(`/simple-upload/files/${fileId}/process-manual`, {
method: 'POST',
body: formData
});
console.log('Manual processing result:', result);
setMessage({ type: 'info', text: 'Manual processing triggered (not yet implemented)' });
} catch (error: unknown) {
console.error('Manual processing failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Manual processing failed: ${errorMessage}` });
}
};
const getStatusColor = (status?: string) => {
switch (status) {
case 'uploaded': return 'primary';
case 'processing': return 'warning';
case 'completed': return 'success';
case 'failed': return 'error';
default: return 'default';
}
};
return (
<Box sx={{ p: 3, maxWidth: 1200, mx: 'auto' }}>
<Typography variant="h4" gutterBottom>
🧪 Simple Upload Test Page
</Typography>
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2">
This page tests the NEW simple upload system (no auto-processing) vs the OLD system (with auto-processing).
Use this to verify that files upload without triggering Docling bundles, Tika runs, etc.
</Typography>
</Alert>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }} onClose={() => setMessage(null)}>
{message.text}
</Alert>
)}
<Grid container spacing={3}>
{/* Upload Controls */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
title="Upload Controls"
avatar={<UploadIcon />}
/>
<CardContent>
<Box sx={{ mb: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Cabinet</InputLabel>
<Select
value={selectedCabinet}
label="Cabinet"
onChange={(e) => setSelectedCabinet(e.target.value)}
>
{cabinets.map(cabinet => (
<MenuItem key={cabinet.id} value={cabinet.id}>
{cabinet.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box sx={{ mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Upload Type</InputLabel>
<Select
value={uploadType}
label="Upload Type"
onChange={(e) => setUploadType(e.target.value as 'old' | 'new')}
>
<MenuItem value="new">🆕 NEW (Simple, No Auto-Processing)</MenuItem>
<MenuItem value="old">🔄 OLD (Auto-Processing)</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={handleSingleUpload}
/>
<Button
variant="outlined"
startIcon={<UploadIcon />}
onClick={() => fileInputRef.current?.click()}
disabled={!selectedCabinet || loading}
>
Upload File
</Button>
<input
ref={dirInputRef}
type="file"
style={{ display: 'none' }}
{...({ webkitdirectory: '' } as any)}
multiple
onChange={handleFallbackDirectorySelect}
/>
<Button
variant="outlined"
startIcon={<FolderOpenIcon />}
onClick={handleDirectoryPicker}
disabled={!selectedCabinet || loading}
>
Upload Directory
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => {
setCurrentPage(1);
setSearchTerm('');
loadFiles(selectedCabinet, 1);
}}
disabled={loading}
>
Refresh
</Button>
</Box>
{isDirectoryPickerSupported() ? (
<Alert severity="success" sx={{ mt: 1 }}>
Modern directory picker supported (Chromium browser)
</Alert>
) : (
<Alert severity="info" sx={{ mt: 1 }}>
Using fallback directory picker (webkitdirectory)
</Alert>
)}
</CardContent>
</Card>
</Grid>
{/* System Info */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
title="System Info"
avatar={<InfoIcon />}
/>
<CardContent>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Current Endpoints:</strong>
</Typography>
<Typography variant="body2">
NEW: <code>/simple-upload/files/upload</code> (no auto-processing)
</Typography>
<Typography variant="body2">
OLD: <code>/database/files/upload</code> (auto-processing disabled for testing)
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Storage Buckets:</strong>
</Typography>
<Typography variant="body2">
Both systems now use: <code>cc.users</code> (teacher scope)
</Typography>
<Typography variant="caption" color="textSecondary">
Files will be stored in the same bucket for consistency
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Selected Cabinet:</strong>
</Typography>
<Typography variant="body2">
{selectedCabinet || 'None selected'}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Upload Mode:</strong>
</Typography>
<Chip
label={uploadType === 'new' ? 'NEW (Simple)' : 'OLD (Auto-Processing)'}
color={uploadType === 'new' ? 'success' : 'warning'}
/>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
<strong>Files in Cabinet:</strong>
</Typography>
<Typography variant="h6">
{pagination ? `${pagination.total_count} total (${files.length} on page ${pagination.page})` : files.length}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
<strong>Pagination:</strong>
</Typography>
<Typography variant="body2">
{itemsPerPage} per page Sort by {sortBy} ({sortOrder})
</Typography>
{searchTerm && (
<Typography variant="caption" color="textSecondary">
Searching: "{searchTerm}"
</Typography>
)}
</Box>
</CardContent>
</Card>
</Grid>
{/* File List */}
<Grid item xs={12}>
<Card>
<CardHeader
title={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FileIcon />
<Typography variant="h6">
Files {pagination && `(${pagination.total_count} total)`}
</Typography>
</Box>
{pagination && (
<Typography variant="body2" color="textSecondary">
Page {pagination.page} of {pagination.total_pages}
</Typography>
)}
</Box>
}
/>
<CardContent>
{/* Search and Filter Controls */}
<Box sx={{ mb: 2, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
label="Search files"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
sx={{ minWidth: 200 }}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={sortBy}
label="Sort by"
onChange={(e) => setSortBy(e.target.value)}
>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="created_at">Date Created</MenuItem>
<MenuItem value="size_bytes">Size</MenuItem>
<MenuItem value="processing_status">Status</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>Order</InputLabel>
<Select
value={sortOrder}
label="Order"
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
>
<MenuItem value="asc">Ascending</MenuItem>
<MenuItem value="desc">Descending</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>Per page</InputLabel>
<Select
value={itemsPerPage}
label="Per page"
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
>
<MenuItem value={5}>5</MenuItem>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={20}>20</MenuItem>
<MenuItem value={50}>50</MenuItem>
</Select>
</FormControl>
</Box>
{/* File List with Fixed Height */}
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
height: 400, // Fixed height
overflow: 'auto'
}}>
{loading ? (
<Box sx={{ p: 2 }}>
<LinearProgress />
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
Loading files...
</Typography>
</Box>
) : files.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="textSecondary">
{searchTerm ? 'No files found matching your search.' : 'No files found. Upload some files to test!'}
</Typography>
</Box>
) : (
<List disablePadding>
{files.map((file, index) => (
<React.Fragment key={file.id}>
<ListItem
secondaryAction={
<Box>
<IconButton
size="small"
onClick={() => handleManualProcessing(file.id)}
title="Trigger manual processing"
>
<ProcessIcon />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(file.id)}
title="Delete file"
color="error"
>
<DeleteIcon />
</IconButton>
</Box>
}
>
<ListItemIcon>
{file.is_directory ? <FolderIcon /> : <FileIcon />}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
{file.name}
</Typography>
{file.is_directory && <Chip label="Directory" size="small" />}
<Chip
label={file.processing_status || 'unknown'}
size="small"
color={getStatusColor(file.processing_status)}
/>
</Box>
}
secondary={
<Box>
<Typography variant="caption" color="textSecondary">
{file.size_bytes ? formatFileSize(file.size_bytes) : 'Unknown size'} {file.mime_type || 'Unknown type'}
</Typography>
{file.relative_path && (
<Typography variant="caption" color="textSecondary" display="block">
Path: {file.relative_path}
</Typography>
)}
</Box>
}
/>
</ListItem>
{index < files.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
)}
</Box>
{/* Pagination Controls */}
{pagination && pagination.total_pages > 1 && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Stack spacing={2} alignItems="center">
<Pagination
count={pagination.total_pages}
page={pagination.page}
onChange={(event, value) => setCurrentPage(value)}
color="primary"
showFirstButton
showLastButton
/>
<Typography variant="caption" color="textSecondary">
Showing {pagination.offset + 1}-{Math.min(pagination.offset + pagination.per_page, pagination.total_count)} of {pagination.total_count} files
</Typography>
</Stack>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Directory Upload Dialog */}
<Dialog open={showUploadDialog} onClose={() => !isUploading && setShowUploadDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<FolderOpenIcon />
Directory Upload Progress
{isUploading && <LinearProgress sx={{ flexGrow: 1, ml: 2 }} />}
</Box>
</DialogTitle>
<DialogContent>
{directoryStats && (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>{directoryStats.fileCount} files</strong> in{' '}
<strong>{directoryStats.directoryCount} folders</strong><br/>
Total size: <strong>{directoryStats.formattedSize}</strong>
</Typography>
</Alert>
)}
<Paper variant="outlined" sx={{ p: 2, maxHeight: 300, overflow: 'auto' }}>
{uploadProgress.map((item, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="body2" sx={{ flex: 1, mr: 2 }}>
{item.path}
</Typography>
<Typography variant="body2" sx={{ mr: 2, minWidth: 80 }}>
{formatFileSize(item.size)}
</Typography>
<Chip
label={item.status}
size="small"
color={
item.status === 'done' ? 'success' :
item.status === 'error' ? 'error' :
item.status === 'uploading' ? 'primary' : 'default'
}
icon={
item.status === 'done' ? <SuccessIcon /> :
item.status === 'error' ? <ErrorIcon /> : undefined
}
/>
</Box>
))}
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowUploadDialog(false)} disabled={isUploading}>
Cancel
</Button>
<Button
onClick={startDirectoryUpload}
variant="contained"
disabled={isUploading || selectedFiles.length === 0}
>
{isUploading ? 'Uploading...' : 'Start Upload'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default SimpleUploadTest;

View File

@ -0,0 +1,471 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { SelectChangeEvent } from '@mui/material/Select';
import { supabase } from '../../../supabaseClient';
type Manifest = {
bucket: string;
entries: Array<{
// Old format
name?: string;
path?: string;
size: number;
content_type?: string;
// New docling_bundle format
filename?: string;
rel_path?: string;
mime_type?: string;
}>;
markdown_full?: string;
markdown_pages?: Array<{ page: number; path: string }>;
html_full?: string;
text_full?: string;
json_full?: string;
doctags_full?: string;
// New docling_bundle format
file_paths?: {
md?: string;
html?: string;
text?: string;
json?: string;
doctags?: string;
};
bundle_type?: string;
};
type Mode = 'markdown_full'|'markdown_pages'|'html_full'|'text_full'|'json_full'|'doctags_full';
export const CCBundleViewer: React.FC<{
fileId: string;
bundleId: string | undefined;
currentPage?: number;
combinedBundles?: Array<{ id: string }>;
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
const [manifest, setManifest] = useState<Manifest | null>(null);
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
const [mode, setMode] = useState<Mode>('markdown_full');
const [content, setContent] = useState<string>('');
const [renderHtml, setRenderHtml] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`;
}, [API_BASE]);
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
if (!s || typeof s !== 'string') return s || '';
return s.split(search).join(replacement);
}, []);
// Normalize manifest format - convert new docling_bundle format to expected format
const normalizeManifest = useCallback((manifest: Manifest): Manifest => {
// If this is a new docling_bundle format with file_paths, convert to expected format
if (manifest.file_paths && manifest.bundle_type === 'docling_bundle') {
return {
...manifest,
markdown_full: manifest.file_paths.md,
html_full: manifest.file_paths.html,
text_full: manifest.file_paths.text,
json_full: manifest.file_paths.json,
doctags_full: manifest.file_paths.doctags,
// Keep original file_paths for reference
file_paths: manifest.file_paths
};
}
return manifest;
}, []);
const buildNameToPath = (m: Manifest | null): Record<string, string> => {
const map: Record<string, string> = {};
if (!m) return map;
console.log('🖼️ Building name-to-path map. Manifest entries:', m.entries?.length || 0);
for (const e of (m.entries || [])) {
if (!e) continue;
// Handle both old format (name/path) and new docling_bundle format (filename/rel_path)
const entryName = e.name || e.filename;
const entryPath = e.path || e.rel_path;
if (!entryName || !entryPath) {
console.log('🖼️ Skipping entry with missing name/path:', e);
continue;
}
// Map filename only (e.g., "image_000000_...png")
const filename = entryName.split('/').pop() || entryName;
map[filename] = entryPath;
// Map full relative path (e.g., "artifacts/image_000000_...png")
map[entryName] = entryPath;
// Map relative path with "./" prefix (e.g., "./artifacts/image_000000_...png")
map[`./${entryName}`] = entryPath;
// For debugging - map any path component variations
if (entryName.includes('/')) {
// Map path without leading directory (e.g., if name is "artifacts/image.png", also map "image.png")
const pathParts = entryName.split('/');
for (let i = 1; i < pathParts.length; i++) {
const partialPath = pathParts.slice(i).join('/');
map[partialPath] = entryPath;
}
}
}
// Debug: log the first few mappings for images
const imageKeys = Object.keys(map).filter(k => k.includes('image_')).slice(0, 3);
console.log('🖼️ Total mappings created:', Object.keys(map).length);
if (imageKeys.length > 0) {
console.log('🖼️ Image path mappings:', imageKeys.map(k => `${k}${map[k]}`));
} else {
console.log('🖼️ No image mappings found. All keys:', Object.keys(map).slice(0, 10));
}
return map;
};
const rewriteHtmlImageSrcs = useCallback((html: string, m: Manifest): string => {
if (!html || typeof html !== 'string') return html || '';
const nameToPath = buildNameToPath(m);
return html.replace(/<img\s+([^>]*?)src=("|')([^"']+)(\2)([^>]*?)>/gi, (_match, pre, q, src, _q2, post) => {
const s = (src || '').trim();
if (s.startsWith('http') || s.startsWith('data:')) return _match; // leave
// Try multiple path resolution strategies
const normalizedKey = s.replace(/^\.\//, '').replace(/^\//, '');
let rel = nameToPath[s] || nameToPath[normalizedKey] || nameToPath[`./${s}`] || nameToPath[`./${normalizedKey}`];
// If still not found, try finding by filename only
if (!rel) {
const filename = normalizedKey.split('/').pop() || normalizedKey;
rel = nameToPath[filename];
}
// If still not found, try partial path matching
if (!rel) {
const matchingKey = Object.keys(nameToPath).find(k =>
k.endsWith(normalizedKey) || k.endsWith(`/${normalizedKey}`) || normalizedKey.endsWith(k)
);
if (matchingKey) {
rel = nameToPath[matchingKey];
}
}
// Debug logging for failed image resolution (less verbose)
if (!rel && s.includes('image_')) {
console.log(`🖼️ HTML: Failed to resolve image: "${s}"`);
}
if (!rel) return _match;
// token added at runtime later; leave placeholder and replace after
const url = `__PROXY__::${rel}`;
return `<img ${pre || ''}src="${url}"${post || ''}>`;
});
}, []);
const markdownToHtmlWithImages = useCallback((md: string, m: Manifest): string => {
if (!md || typeof md !== 'string') return '';
// Replace images ![alt](path) with img tags that proxy to storage
const nameToPath = buildNameToPath(m);
let html = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
const s = String(url || '').trim();
const altText = String(alt || '');
if (s.startsWith('http') || s.startsWith('data:')) return `<img alt="${altText}" src="${s}">`;
// Try multiple path resolution strategies (same as HTML rewriting)
const normalizedKey = s.replace(/^\.\//, '').replace(/^\//, '');
let rel = nameToPath[s] || nameToPath[normalizedKey] || nameToPath[`./${s}`] || nameToPath[`./${normalizedKey}`];
// If still not found, try finding by filename only
if (!rel) {
const filename = normalizedKey.split('/').pop() || normalizedKey;
rel = nameToPath[filename];
}
// If still not found, try partial path matching
if (!rel) {
const matchingKey = Object.keys(nameToPath).find(k =>
k.endsWith(normalizedKey) || k.endsWith(`/${normalizedKey}`) || normalizedKey.endsWith(k)
);
if (matchingKey) {
rel = nameToPath[matchingKey];
}
}
// Debug logging for failed image resolution (less verbose)
if (!rel && s.includes('image_')) {
console.log(`🖼️ Markdown: Failed to resolve image: "${s}"`);
}
const prox = rel ? `__PROXY__::${rel}` : s; // Use original path as fallback
return `<img alt="${altText}" src="${prox}">`;
});
// Minimal paragraph handling
if (html && typeof html === 'string') {
html = html
.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br/>')}</p>`).join('\n');
}
return html || '';
}, []);
useEffect(() => {
const load = async () => {
setError(null);
setCombinedManifests(null);
setManifest(null);
if (combinedBundles && combinedBundles.length > 0) {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const ms: Manifest[] = [];
for (const b of combinedBundles) {
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) continue;
const rawManifest = await res.json();
ms.push(normalizeManifest(rawManifest));
}
setCombinedManifests(ms);
} catch (e: unknown) {
setCombinedManifests(null);
setError(e instanceof Error ? e.message : 'Failed to load combined manifests');
}
return;
}
if (!bundleId) return;
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(await res.text());
const rawManifest: Manifest = await res.json();
const normalizedManifest = normalizeManifest(rawManifest);
setManifest(normalizedManifest);
} catch (e: unknown) {
setManifest(null);
setError(e instanceof Error ? e.message : 'Failed to load bundle manifest');
}
};
load();
}, [fileId, bundleId, API_BASE, combinedBundles, normalizeManifest]);
useEffect(() => {
const loadContent = async () => {
// Combined mode
if (combinedManifests && combinedManifests.length > 0) {
setLoading(true); setError(null);
try {
const bucket = combinedManifests[0]?.bucket || '';
// Build combined output depending on selected mode. If selected mode
// is not available for a part, fall back: markdown_full → html_full → text_full → json_full
let htmlParts: string[] = [];
let textParts: string[] = [];
let jsonParts: string[] = [];
for (const m of combinedManifests) {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
let rel: string | undefined;
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full;
else if (mode === 'text_full') rel = m.text_full || m.markdown_full || m.html_full || m.json_full;
else if (mode === 'json_full') rel = m.json_full || m.text_full || m.markdown_full || m.html_full;
else if (mode === 'doctags_full') rel = m.doctags_full || m.json_full || m.text_full || m.markdown_full;
else if (mode === 'markdown_pages') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
if (!rel) continue;
const url = await proxyUrl(m.bucket || bucket, rel);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) continue;
const ct = res.headers.get('Content-Type') || '';
if ((mode === 'markdown_full' && m.markdown_full && rel === m.markdown_full) || (!m.html_full && !ct.includes('text/html') && !ct.includes('application/json'))) {
// Treat as markdown
const md = await res.text();
if (!md || typeof md !== 'string') continue;
let h = markdownToHtmlWithImages(md, m);
// Replace placeholders with signed proxy URLs
const matches = [...h.matchAll(/__PROXY__::([^"'>\s]+)/g)].map((mm: RegExpMatchArray) => mm[1]);
const unique = Array.from(new Set(matches));
for (const r of unique) {
const p = await proxyUrl(m.bucket || bucket, r);
h = replaceAllSafe(h, `__PROXY__::${r}`, p);
}
htmlParts.push(h);
textParts.push(md);
} else if ((mode === 'html_full' && m.html_full && rel === m.html_full) || ct.includes('text/html')) {
let htxt = await res.text();
if (!htxt || typeof htxt !== 'string') continue;
let h = rewriteHtmlImageSrcs(htxt, m);
const matches = [...h.matchAll(/__PROXY__::([^"'>\s]+)/g)].map((mm: RegExpMatchArray) => mm[1]);
const unique = Array.from(new Set(matches));
for (const r of unique) {
const p = await proxyUrl(m.bucket || bucket, r);
h = replaceAllSafe(h, `__PROXY__::${r}`, p);
}
htmlParts.push(h);
textParts.push(htxt);
} else if (ct.includes('application/json') || mode === 'doctags_full' || (mode === 'json_full' && rel === m.doctags_full)) {
const js = await res.json();
jsonParts.push(JSON.stringify(js, null, 2));
} else {
const t = await res.text();
if (t && typeof t === 'string') {
textParts.push(t);
}
}
}
if ((mode === 'json_full' || mode === 'doctags_full') && jsonParts.length > 0 && htmlParts.length === 0) {
setRenderHtml('');
setContent(jsonParts.join('\n\n'));
} else if (htmlParts.length > 0) {
setRenderHtml(htmlParts.join('<hr/>'));
setContent('');
} else {
setContent(textParts.join('\n\n'));
setRenderHtml('');
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load combined content');
} finally {
setLoading(false);
}
return;
}
if (!manifest) { setContent(''); return; }
setLoading(true); setError(null);
try {
const bucket = manifest.bucket || '';
let relPath: string | undefined = undefined;
if (mode === 'markdown_full') relPath = manifest.markdown_full;
else if (mode === 'html_full') relPath = manifest.html_full;
else if (mode === 'text_full') relPath = manifest.text_full;
else if (mode === 'json_full') relPath = manifest.json_full;
else if (mode === 'doctags_full') relPath = manifest.doctags_full;
else if (mode === 'markdown_pages') {
const p = Math.max(1, (currentPage || 1));
const rec = (manifest.markdown_pages || []).find(x => x.page === p) || (manifest.markdown_pages || [])[0];
relPath = rec?.path;
}
if (!relPath) { setContent(''); setLoading(false); return; }
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const url = await proxyUrl(bucket, relPath);
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const js = await res.json();
setContent(JSON.stringify(js, null, 2));
setRenderHtml('');
} else {
let txt = await res.text();
if (!txt || typeof txt !== 'string') {
setContent('');
setRenderHtml('');
setLoading(false);
return;
}
// Dev fallback: if we accidentally hit Vite index, refetch from fallback base
if ((ct.includes('text/html') && txt.includes('/@vite/client')) && API_BASE !== API_BASE_FALLBACK) {
const alt = url.replace(API_BASE, API_BASE_FALLBACK);
res = await fetch(alt, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) {
txt = await res.text();
}
}
setContent(txt);
// Prepare renderable HTML if markdown or html modes
if ((mode === 'markdown_full' || mode === 'markdown_pages') && txt && typeof txt === 'string') {
let html = markdownToHtmlWithImages(txt, manifest);
// Replace placeholders with signed proxy URLs
if (html && typeof html === 'string') {
const tokenUrl = async (rel: string) => await proxyUrl(bucket, rel);
const matches = [...html.matchAll(/__PROXY__::([^"'>\s]+)/g)].map(m => m[1]);
const unique = Array.from(new Set(matches));
for (const rel of unique) {
const p = await tokenUrl(rel);
html = replaceAllSafe(html, `__PROXY__::${rel}`, p);
}
setRenderHtml(html);
} else {
setRenderHtml('');
}
} else if (mode === 'html_full' && txt && typeof txt === 'string') {
let html = rewriteHtmlImageSrcs(txt, manifest);
if (html && typeof html === 'string') {
const matches = [...html.matchAll(/__PROXY__::([^"'>\s]+)/g)].map(m => m[1]);
const unique = Array.from(new Set(matches));
for (const rel of unique) {
const p = await proxyUrl(bucket, rel);
html = replaceAllSafe(html, `__PROXY__::${rel}`, p);
}
setRenderHtml(html);
} else {
setRenderHtml('');
}
} else {
setRenderHtml('');
}
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load bundle content');
} finally { setLoading(false); }
};
loadContent();
}, [manifest, mode, currentPage, API_BASE, fileId, combinedManifests, markdownToHtmlWithImages, rewriteHtmlImageSrcs, proxyUrl, replaceAllSafe]);
const availableModes = useMemo(() => {
const hasCombined = !!(combinedManifests && combinedManifests.length);
const has = (field: keyof Manifest): boolean => {
if (hasCombined) {
return combinedManifests!.some((cm: Manifest) => {
const v = cm[field as keyof Manifest] as unknown;
return Array.isArray(v) ? v.length > 0 : Boolean(v);
});
}
const v = manifest ? (manifest[field as keyof Manifest] as unknown) : undefined;
return Array.isArray(v) ? (v as unknown[]).length > 0 : Boolean(v);
};
const m: Array<{ key: typeof mode; label: string; enabled: boolean }> = [
{ key: 'markdown_full', label: 'Markdown (full)', enabled: has('markdown_full') },
{ key: 'markdown_pages', label: 'Markdown (pages)', enabled: !hasCombined && !!manifest?.markdown_pages?.length },
{ key: 'html_full', label: 'HTML (full)', enabled: has('html_full') },
{ key: 'text_full', label: 'Text (full)', enabled: has('text_full') },
{ key: 'json_full', label: 'JSON (full)', enabled: has('json_full') },
{ key: 'doctags_full', label: 'DocTags (full)', enabled: has('doctags_full') },
];
const first = m.find(x => x.enabled)?.key;
if (first && !m.find(x => x.key === mode && x.enabled)) setMode(first);
return m;
}, [manifest, combinedManifests, mode]);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Docling artefact</Typography>
<Select size="small" value={mode} onChange={(e: SelectChangeEvent<Mode>) => setMode(e.target.value as Mode)}>
{availableModes.map(m => (
<MenuItem key={m.key} value={m.key} disabled={!m.enabled}>{m.label}</MenuItem>
))}
</Select>
</Box>
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{loading ? <CircularProgress size={18} /> : error ? <Box sx={{ color: 'var(--color-text-2)' }}>{error}</Box> : (
(mode === 'markdown_full' || mode === 'markdown_pages' || mode === 'html_full') && renderHtml ? (
<iframe
title="docling-html"
style={{ width: '100%', height: '100%', border: 'none' }}
srcDoc={`<!doctype html><html><head><meta charset='utf-8'><style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Arial,sans-serif;padding:16px;color:#222} img{max-width:100%;height:auto} pre,code{white-space:pre-wrap;word-break:break-word}</style></head><body>${renderHtml}</body></html>`}
/>
) : (
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{content}</pre>
)
)}
</Box>
</Box>
);
};
export default CCBundleViewer;

View File

@ -0,0 +1,403 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, IconButton } from '@mui/material';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { supabase } from '../../../supabaseClient';
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
type DoclingJson = Record<string, unknown> & {
pages?: Array<{
image_base64?: string;
image?: { uri?: string; image_base64?: string; mimetype?: string };
width?: number;
height?: number;
}> | Record<string, unknown>;
page_images?: Array<{ uri?: string; image_base64?: string }>;
images?: Array<{ uri?: string; image_base64?: string }>;
frontpage?: { image_base64?: string };
cover?: { image_base64?: string };
};
type PageImagesManifest = {
version: number;
file_id: string;
page_count: number;
bucket?: string;
base_dir?: string;
page_images: Array<{
page: number;
full_image_path: string;
thumbnail_path: string;
full_dimensions?: { width: number; height: number };
thumbnail_dimensions?: { width: number; height: number };
}>
};
export const CCDoclingViewer: React.FC<{
fileId: string;
currentPage?: number;
onPageChange?: (page: number) => void;
onExtractedText?: (text: string) => void;
onTotalPagesChange?: (total: number) => void;
hideToolbar?: boolean;
sectionRange?: { start: number; end: number };
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
const [pageLocal, setPageLocal] = useState<number>(1);
const page = typeof currentPage === 'number' ? currentPage : pageLocal;
const norm = (s: unknown): string => String(s ?? '')
.replace(/\r\n/g, '\n')
.replace(/\t/g, ' ')
.replace(/[ \f\v]{2,}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
const asRecord = (v: unknown): Record<string, unknown> => (v && typeof v === 'object') ? (v as Record<string, unknown>) : {};
const asArray = (v: unknown): unknown[] => Array.isArray(v) ? v : [];
const getText = (n: unknown): string => {
const node = asRecord(n);
const cands = [
node['text'], node['orig'], node['content'], node['value'],
node['md'], node['markdown'], node['plain_text'], node['caption'], node['title']
];
const val = cands.find((v): v is string => typeof v === 'string' && v.trim().length > 0);
return norm(val ?? '');
};
const collectSimpleText = (doc: unknown): string => {
const docRec = asRecord(doc);
const d = asRecord(asRecord(docRec['document'])['json_content'] ?? docRec['json_content'] ?? docRec);
const parts: string[] = [];
for (const t of asArray(d['texts'])) {
const txt = getText(t);
if (txt) parts.push(txt);
}
for (const li of asArray(d['lists'])) {
const items = asArray(asRecord(li)['items']).map(getText).filter(Boolean) as string[];
if (items.length) parts.push(norm(items.join('\n')));
}
for (const tbl of asArray(d['tables'])) {
const data = asRecord(asRecord(tbl)['data']);
const grid = asArray(data['grid']);
if (grid.length) {
const rows = grid.map((row) => `| ${asArray(row).map((c) => getText(c)).join(' | ')} |`);
if (rows.length >= 2) {
const firstLen = asArray(grid[0]).length;
rows.splice(1, 0, `| ${Array(firstLen).fill('---').join(' | ')} |`);
}
if (rows.length) parts.push(norm(rows.join('\n')));
} else {
const rowsArr = asArray(data['rows']);
if (rowsArr.length) {
const rows: string[] = [];
for (const r of rowsArr) {
const rRec = asRecord(r);
const cells = (asArray(rRec['cells']).length ? asArray(rRec['cells']) : asArray(r)).map((c) => getText(c));
rows.push(`| ${cells.join(' | ')} |`);
}
if (rows.length) parts.push(norm(rows.join('\n')));
}
}
}
return norm(parts.join('\n\n'));
};
const extractImages = (rawDoc: unknown): Array<{ src: string; width?: number; height?: number }> => {
const docRec = asRecord(rawDoc);
const d = asRecord(asRecord(docRec['document'])['json_content'] ?? docRec['json_content'] ?? docRec);
const out: Array<{ src: string; width?: number; height?: number }> = [];
const pushUri = (uri?: string) => {
if (!uri) return;
if (uri.startsWith('data:')) out.push({ src: uri });
else if (/^[A-Za-z0-9+/=]+$/.test(uri)) out.push({ src: `data:image/png;base64,${uri}` });
};
// Case 1: pages is an array with image_base64 or image.uri
const pagesVal = d['pages'];
if (Array.isArray(pagesVal)) {
for (const p of pagesVal) {
const pRec = asRecord(p);
const image = asRecord(pRec['image']);
const b64 = pRec['image_base64'] as string | undefined;
const b64img = image['image_base64'] as string | undefined;
const uri = image['uri'] as string | undefined;
if (b64) out.push({ src: `data:image/png;base64,${b64}` });
else if (b64img) out.push({ src: `data:image/png;base64,${b64img}` });
else if (uri) pushUri(uri);
}
}
// Case 2: pages is an object keyed by page number, each with image.uri
if (!out.length && pagesVal && typeof pagesVal === 'object' && !Array.isArray(pagesVal)) {
const pagesRec = asRecord(pagesVal);
const keys = Object.keys(pagesRec).sort((a, b) => Number(a) - Number(b));
for (const k of keys) {
const pRec = asRecord(pagesRec[k]);
const img = asRecord(pRec['image']);
const uri = img['uri'] as string | undefined;
const b64 = pRec['image_base64'] as string | undefined;
if (uri) pushUri(uri);
else if (b64) out.push({ src: `data:image/png;base64,${b64}` });
}
}
// Case 3: page_images or images arrays with data URIs
if (!out.length && Array.isArray(d['page_images'])) {
for (const im of d['page_images'] as Array<Record<string, unknown>>) pushUri((im['uri'] as string | undefined) || (im['image_base64'] as string | undefined));
}
if (!out.length && Array.isArray(d['images'])) {
for (const im of d['images'] as Array<Record<string, unknown>>) pushUri((im['uri'] as string | undefined) || (im['image_base64'] as string | undefined));
}
// Fallback: frontpage/cover only
const front = asRecord(d['frontpage']);
const cover = asRecord(d['cover']);
const frontB64 = front['image_base64'] as string | undefined;
const coverB64 = cover['image_base64'] as string | undefined;
if (!out.length && (frontB64 || coverB64)) {
const src = frontB64 || coverB64;
if (src) out.push({ src: `data:image/png;base64,${src}` });
}
return out;
};
useEffect(() => {
const run = async () => {
if (!fileId) return;
setLoading(true);
setError(null);
try {
// Try page-images manifest first
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (mRes.ok) {
const m: PageImagesManifest = await mRes.json();
setManifest(m);
setImages([]); // we will render via manifest in viewer
if (!currentPage) setPageLocal(1);
return; // skip legacy docling path
}
} catch (e) {
// ignore and fallback to legacy
}
// Legacy: Load artefacts for file to find docling JSON artefacts
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
const artefacts: Artefact[] = await artefactsRes.json();
// Prefer full-file no-OCR artefact for complete page images
const noocr = artefacts.find(a => a.type === 'docling_noocr_json');
const frontmatter = artefacts.find(a => a.type === 'docling_frontmatter_json');
const target = noocr || frontmatter;
if (!target) {
setError('No Docling artefacts found. Generate initial artefacts from the file menu.');
setImages([]);
return;
}
// Download artefact JSON via backend (service-role) to avoid RLS issues
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!jsonRes.ok) throw new Error(await jsonRes.text());
const doc: DoclingJson = await jsonRes.json();
const imgs = extractImages(doc);
setImages(imgs);
if (onExtractedText) {
const text = collectSimpleText(doc);
onExtractedText(text);
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load document');
setImages([]);
} finally {
setLoading(false);
}
};
run();
}, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
const pageProxyUrl = useMemo(() => {
if (!manifest) return undefined;
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, (page || 1) - 1));
const pg = manifest.page_images[idx];
if (!pg) return undefined;
const bucket = manifest.bucket || '';
const path = pg.full_image_path;
return `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
}, [manifest, page, API_BASE]);
const [pageObjectUrl, setPageObjectUrl] = useState<string | undefined>(undefined);
const [cacheUrls] = useState<Map<number, string>>(() => new Map());
useEffect(() => {
let revoked: string | null = null;
const load = async () => {
if (!pageProxyUrl || !manifest) {
setPageObjectUrl(undefined);
return;
}
// Cache by page number to avoid repeated fetches
const key = page;
const cached = cacheUrls.get(key);
if (cached) {
setPageObjectUrl(cached);
return;
}
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok && manifest) {
// Fallback to thumbnail if the full image is not accessible yet
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, (page || 1) - 1));
const pg = manifest.page_images[idx];
if (pg) {
const thumbUrl = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
resp = await fetch(thumbUrl, { headers: { Authorization: `Bearer ${token}` } });
}
}
if (!resp.ok) {
setError(`Failed to load page ${page}: ${resp.status}`);
setPageObjectUrl(undefined);
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
cacheUrls.set(key, url);
setPageObjectUrl(url);
revoked = url;
};
load();
return () => {
// Do not revoke cached urls immediately; only revoke if it's a temp assignment
// We keep the cache for navigation performance.
if (revoked && ![...cacheUrls.values()].includes(revoked)) {
URL.revokeObjectURL(revoked);
}
};
}, [pageProxyUrl, manifest, page, cacheUrls]);
const totalPages = manifest?.page_count || images.length || 1;
// Inform parent about total pages when it changes
useEffect(() => {
if (onTotalPagesChange) onTotalPagesChange(totalPages);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [totalPages]);
const handlePageChange = (p: number) => {
const clamped = Math.max(1, Math.min(totalPages, p));
if (onPageChange) onPageChange(clamped);
else setPageLocal(clamped);
};
const content = useMemo(() => {
if (loading) return <Box sx={{ p: 2 }}><CircularProgress size={20} /></Box>;
if (error) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error}</Box>;
// New single-page view using manifest
if (manifest) {
// Multi-page section view
const start = sectionRange?.start ?? page;
const end = sectionRange?.end ?? page;
const pages: number[] = [];
for (let p = start; p <= Math.min(end, totalPages); p++) pages.push(p);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
{!hideToolbar && (
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, borderBottom: '1px solid var(--color-divider)' }}>
<IconButton size="small" onClick={() => handlePageChange(start - 1)} disabled={start <= 1}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
<Box sx={{ fontSize: 12, color: 'var(--color-text-2)' }}>Section {start}{end}</Box>
<IconButton size="small" onClick={() => handlePageChange(end + 1)} disabled={end >= totalPages}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
</Box>
)}
<Box sx={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, p: 2 }}>
{pages.map((p) => {
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, p - 1));
const pg = manifest.page_images[idx];
if (!pg) return null;
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.full_image_path)}`;
return (
<ImageByProxy key={p} url={url} alt={`Page ${p}`} />
);
})}
</Box>
</Box>
);
}
// Fallback legacy rendering
if (!images.length) return <Box sx={{ p: 2 }}>No page images available.</Box>;
return (
<Box sx={{ width: '100%', height: '100%', overflow: 'auto', p: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
{images.map((img, i) => (
<img key={i} src={img.src} alt={`Page ${i + 1}`} style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.15)', maxWidth: '100%' }} />
))}
</Box>
</Box>
);
}, [loading, error, images, manifest, pageObjectUrl, page, totalPages]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
{content}
</Box>
);
};
export default CCDoclingViewer;
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let revoked: string | null = null;
const load = async () => {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const obj = URL.createObjectURL(blob);
setBlobUrl(obj);
revoked = obj;
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load page');
} finally {
setLoading(false);
}
};
load();
return () => { if (revoked) URL.revokeObjectURL(revoked); };
}, [url]);
if (loading) return <Box sx={{ p: 2 }}><CircularProgress size={18} /></Box>;
if (error || !blobUrl) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error || 'No image'}</Box>;
return (
<img src={blobUrl} alt={alt} style={{ maxWidth: '100%', height: 'auto', display: 'block', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }} />
);
};

View File

@ -0,0 +1,605 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Box, Button, Divider, FormControlLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import { SelectChangeEvent } from '@mui/material/Select';
// import { CCFilesPanel } from '../../../utils/tldraw/ui-overrides/components/shared/CCFilesPanel';
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
import CCBundleViewer from './CCBundleViewer.tsx';
import { supabase } from '../../../supabaseClient';
type CanonicalDoclingConfig = {
pipeline: 'standard' | 'vlm' | 'asr';
pdf_backend: 'dlparse_v4' | 'pypdfium2' | 'dlparse_v1' | 'dlparse_v2';
do_ocr: boolean;
force_ocr: boolean;
table_mode: 'fast' | 'accurate';
do_picture_classification: boolean;
do_picture_description: boolean;
picture_description_prompt?: string;
// Extended options
target_type?: 'inbody' | 'zip';
image_export_mode?: 'placeholder' | 'embedded' | 'referenced';
table_cell_matching?: boolean;
picture_description_local?: string; // JSON string per API
picture_description_api?: string; // JSON string per API
vlm_pipeline_model?: string;
vlm_pipeline_model_local?: string; // JSON string per API
vlm_pipeline_model_api?: string; // JSON string per API
to_formats?: string[];
do_formula_enrichment?: boolean;
do_code_enrichment?: boolean;
};
type CanonicalDoclingRequest = {
use_split_map: boolean;
config: CanonicalDoclingConfig;
threshold: number;
};
type Profile = 'default' | 'simple' | 'aggressive';
type Pipeline = 'standard' | 'vlm' | 'asr';
type PdfBackend = 'dlparse_v4' | 'pypdfium2' | 'dlparse_v1' | 'dlparse_v2';
type TableMode = 'fast' | 'accurate';
export const CCDocumentIntelligence: React.FC = () => {
const { fileId } = useParams<{ fileId: string }>();
const validFileId = useMemo(() => fileId || '', [fileId]);
const [page, setPage] = useState<number>(1);
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
const [profile, setProfile] = useState<Profile>('default');
const [pipeline, setPipeline] = useState<Pipeline>('standard');
// VLM pipeline config (mutually exclusive options)
type VlmMode = 'preset' | 'local' | 'api';
const [vlmMode, setVlmMode] = useState<VlmMode>('preset');
const [vlmPreset, setVlmPreset] = useState<string>('smoldocling');
const [vlmLocalJson, setVlmLocalJson] = useState<string>('');
const [vlmApiJson, setVlmApiJson] = useState<string>('');
type VlmProvider = 'ollama' | 'openai' | '';
const [vlmProvider, setVlmProvider] = useState<VlmProvider>('');
const [vlmProviderModel, setVlmProviderModel] = useState<string>('');
const [vlmProviderBaseUrl, setVlmProviderBaseUrl] = useState<string>('');
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [pdfBackend, setPdfBackend] = useState<PdfBackend>('dlparse_v4');
const [doOCR, setDoOCR] = useState(true);
const [forceOCR, setForceOCR] = useState(false);
const [tableMode, setTableMode] = useState<TableMode>('fast');
const [doPicClass, setDoPicClass] = useState(false);
const [doPicDesc, setDoPicDesc] = useState(false);
const [picDescPrompt, setPicDescPrompt] = useState('Describe the image succinctly for study notes.');
// Picture description config (mutually exclusive local/api)
type PicDescMode = 'local' | 'api';
const [picDescMode, setPicDescMode] = useState<PicDescMode>('local');
const [picDescLocalJson, setPicDescLocalJson] = useState<string>('');
const [picDescApiJson, setPicDescApiJson] = useState<string>('');
const [busy, setBusy] = useState(false);
// Split sections (from split_map)
const [splitSections, setSplitSections] = useState<Array<{ id: string; title: string; start: number; end: number }>>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>('full');
// Load available canonical bundles
type Artefact = { id: string; type: string; rel_path: string; extra?: Record<string, unknown>; created_at?: string };
const [bundles, setBundles] = useState<Artefact[]>([]);
const [currentBundle, setCurrentBundle] = useState<string>('');
const [combineSplit, setCombineSplit] = useState<boolean>(false);
// Batch selection (group of split bundles or single bundle)
type BundleGroup = { key: string; label: string; bundleIds: string[]; isGroup: boolean };
const groupItems = useMemo<BundleGroup[]>(() => {
if (!bundles.length) return [];
const byGroup: Record<string, { ids: string[]; meta: { created_at?: string; pipeline?: string; group_pack_type?: string; producer?: string; ocr_mode?: string; processing_mode?: string; bundle_type?: string }[] }> = {};
const singles: BundleGroup[] = [];
for (const b of bundles) {
const ex = (b.extra as Record<string, unknown>) || {};
const gid = (ex.group_id as string | undefined) || '';
if (gid) {
if (!byGroup[gid]) byGroup[gid] = { ids: [], meta: [] };
byGroup[gid].ids.push(b.id);
const pipeline = (ex.pipeline as string | undefined) || (b.type === 'docling_vlm' ? 'vlm' : (b.type === 'vlm_section_page_bundle' ? 'vlm-pages' : 'standard'));
const producer = (ex.producer as string | undefined) || 'manual';
const do_ocr = ((ex.config as Record<string, unknown>)?.do_ocr as boolean) ?? true;
const ocrLabel = do_ocr ? 'OCR' : 'no-OCR';
byGroup[gid].meta.push({
created_at: b.created_at,
pipeline,
group_pack_type: ex.group_pack_type as string | undefined,
producer,
ocr_mode: ocrLabel,
processing_mode: ex.processing_mode as string | undefined,
bundle_type: ex.bundle_type as string | undefined
});
} else {
const pipeline = (ex.pipeline as string | undefined) || (b.type === 'docling_vlm' ? 'vlm' : (b.type === 'vlm_section_page_bundle' ? 'vlm-pages' : 'standard'));
const producer = (ex.producer as string | undefined) || 'manual';
const producerLabel = producer === 'auto_split' ? 'auto' : 'manual';
singles.push({
key: `single:${b.id}`,
label: `${new Date(b.created_at || '').toLocaleString()}${pipeline}${producerLabel}`,
bundleIds: [b.id],
isGroup: false
});
}
}
const groups: BundleGroup[] = Object.entries(byGroup)
.map(([gid, v]) => {
const newest = v.meta.sort((a,b)=> new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime())[0];
const producerLabel = newest.producer === 'auto_split' ? 'auto' : 'manual';
// Determine pack type - use processing_mode as fallback for better detection
let packType = newest.group_pack_type;
if (!packType) {
// Smart fallback based on bundle characteristics
if (newest.processing_mode === 'whole_document' || newest.bundle_type === 'docling_bundle') {
packType = 'whole';
} else if (v.ids.length === 1) {
packType = 'single';
} else {
packType = 'split';
}
}
const ocrInfo = v.meta.length > 0 ? `${newest.ocr_mode || 'mixed'}` : '';
const label = `${new Date(newest.created_at || '').toLocaleString()}${packType}${newest.pipeline || 'standard'}${ocrInfo}${v.ids.length} parts • ${producerLabel}`;
return { key: `group:${gid}`, label, bundleIds: v.ids, isGroup: v.ids.length > 1 };
})
.sort((a,b)=> new Date(byGroup[b.key.split(':')[1]]?.meta[0]?.created_at || 0).getTime() - new Date(byGroup[a.key.split(':')[1]]?.meta[0]?.created_at || 0).getTime());
return [...groups, ...singles];
}, [bundles]);
const [selectedGroupKey, setSelectedGroupKey] = useState<string>('');
useEffect(() => {
const loadBundles = async () => {
if (!validFileId) return;
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return;
const arts: Artefact[] = await res.json();
const list = arts.filter(a => a.type === 'docling_standard' || a.type === 'docling_vlm' || a.type === 'vlm_section_page_bundle' || a.type === 'docling_bundle' || a.type === 'docling_bundle_split' || a.type === 'docling_bundle_split_pages' || a.type === 'canonical_docling_json')
.sort((a, b) => {
// Sort by creation time, newest first
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return tb - ta;
});
setBundles(list);
// Initialize currentBundle if not set
if (list.length && !currentBundle) {
setCurrentBundle(list[0].id);
}
// Initialize selected group key to latest group or single
const gi = (() => {
const arr = list;
const withGroup = arr.filter(a => ((a.extra as Record<string, unknown>)||{}).group_id);
if (withGroup.length) {
const gid = ((withGroup[0].extra as Record<string, unknown>).group_id as string);
return `group:${gid}`;
}
return `single:${arr[0]?.id || ''}`;
})();
if (!selectedGroupKey && gi) {
setSelectedGroupKey(gi);
}
};
loadBundles();
}, [validFileId]); // Remove circular dependencies to prevent timing issues
// eslint-disable-next-line react-hooks/exhaustive-deps
// Separate effect to handle initialization after bundles are loaded
useEffect(() => {
if (bundles.length > 0 && !currentBundle) {
setCurrentBundle(bundles[0].id);
}
}, [bundles, currentBundle]);
// Separate effect to sync selectedGroupKey with currentBundle
useEffect(() => {
if (bundles.length > 0 && currentBundle && !selectedGroupKey) {
const bundle = bundles.find(b => b.id === currentBundle);
if (bundle) {
const extra = bundle.extra as Record<string, unknown> || {};
const groupId = extra.group_id as string;
if (groupId) {
setSelectedGroupKey(`group:${groupId}`);
} else {
setSelectedGroupKey(`single:${currentBundle}`);
}
}
}
}, [bundles, currentBundle, selectedGroupKey]);
const [splitThreshold] = useState<number>(50);
const autoSplit = useMemo(() => {
const pages = splitSections.reduce((m, s) => Math.max(m, s.end), 0);
return pages >= splitThreshold && splitSections.length > 0;
}, [splitSections, splitThreshold]);
const [doFormula, setDoFormula] = useState(false);
const [doCode, setDoCode] = useState(false);
const [tableCellMatching, setTableCellMatching] = useState<boolean>(false);
// Outputs are fixed to all formats for canonical bundles
useEffect(() => {
const run = async () => {
if (!validFileId) return;
setOutlineOptions([]);
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!artsRes.ok) return;
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (!outlineArt) return;
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!jsonRes.ok) return;
const doc = await jsonRes.json();
const sections = (doc.sections || []) as Array<{ id: string; title: string; start_page: number; end_page: number }>;
setOutlineOptions(sections.map(s => ({ id: s.id, title: s.title, start_page: s.start_page, end_page: s.end_page })));
// Load split map
const splitArt = arts.find(a => a.type === 'split_map_json');
if (splitArt) {
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (smRes.ok) {
const sm = await smRes.json();
const entries = Array.isArray(sm.entries) ? sm.entries : [];
const secs = (entries as Array<Record<string, unknown>>)
.map((e) => ({
id: String((e.id as string) || `${e.start_page as number}-${e.end_page as number}`),
title: String((e.title as string) || ''),
start: Number((e.start_page as number) || 1),
end: Number((e.end_page as number) || 1)
}))
.filter((e) => Number.isFinite(e.start) && Number.isFinite(e.end));
setSplitSections(secs);
}
}
} catch {
// ignore
}
};
run();
}, [validFileId]);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', overflow: 'hidden' }}>
<Box sx={{ width: 320, height: '100%', borderRight: '1px solid var(--color-divider)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flex: 1, minHeight: 0 }}>
<CCEnhancedFilePanel
fileId={validFileId}
selectedPage={page}
onSelectPage={setPage}
currentSection={(function(){
const s = [...outlineOptions].sort((a,b)=>a.start_page-b.start_page).find(x => page >= x.start_page && page <= x.end_page);
return s ? { start: s.start_page, end: s.end_page } : undefined;
})()}
/>
</Box>
</Box>
<Box sx={{ flex: 1, height: '100%', position: 'relative', display: 'flex', flexDirection: 'row' }}>
<Box sx={{ flex: 1, minWidth: 0, borderRight: '1px solid var(--color-divider)', display: 'flex', flexDirection: 'column' }}>
<CCDoclingViewer
fileId={validFileId}
currentPage={page}
onPageChange={setPage}
hideToolbar
sectionRange={(function(){
const s = [...outlineOptions].sort((a,b)=>a.start_page-b.start_page).find(x => page >= x.start_page && page <= x.end_page);
return s ? { start: s.start_page, end: s.end_page } : undefined;
})()}
/>
</Box>
<Box sx={{ width: '42%', minWidth: 320, display: 'flex', flexDirection: 'column' }}>
<CCBundleViewer
fileId={validFileId}
bundleId={!combineSplit ? currentBundle : undefined}
currentPage={page}
combinedBundles={combineSplit ? (function(){
const grp = groupItems.find(g => g.key === selectedGroupKey);
if (!grp) return [];
// Order split parts by split_order if present
const inGroup = bundles.filter(b => grp.bundleIds.includes(b.id));
const ordered = inGroup.sort((a,b) => {
const ao = Number(((a.extra as Record<string, unknown>)||{}).split_order) || 0;
const bo = Number(((b.extra as Record<string, unknown>)||{}).split_order) || 0;
return ao - bo;
});
return ordered.map(b => ({ id: b.id }));
})() : undefined}
/>
</Box>
</Box>
<Box sx={{ width: 360, height: '100%', borderLeft: '1px solid var(--color-divider)', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, fontWeight: 600 }}>AI Document Intelligence</Box>
<Divider />
<Box sx={{ p: 2, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)', fontWeight: 600 }}>Canonical Docling</Typography>
{bundles.length > 0 && (
<>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Existing bundles</Typography>
{/* Batch selector (groups and singles) */}
<Select size="small" value={selectedGroupKey} onChange={(e: SelectChangeEvent<string>) => {
const key = e.target.value as string;
setSelectedGroupKey(key);
const grp = groupItems.find(g => g.key === key);
if (grp && grp.bundleIds.length) setCurrentBundle(grp.bundleIds[0]);
setCombineSplit(Boolean(grp && grp.isGroup));
}}>
{groupItems.map(g => (
<MenuItem key={g.key} value={g.key}>{g.label}</MenuItem>
))}
</Select>
{/* Only show combine toggle if multi-bundle group selected */}
{(() => {
const grp = groupItems.find(g => g.key === selectedGroupKey);
return grp && grp.isGroup ? (
<FormControlLabel control={<Switch checked={combineSplit} onChange={(e) => setCombineSplit(e.target.checked)} />} label="Combine split bundles" />
) : null;
})()}
{/* When not combining, allow selecting a single bundle within selected group */}
{!combineSplit && (() => {
const grp = groupItems.find(g => g.key === selectedGroupKey);
return grp && grp.isGroup; // Only show for groups with multiple bundles
})() && (
<Select size="small" value={currentBundle} onChange={(e) => setCurrentBundle(e.target.value as string)}>
{bundles.filter(b => {
const grp = groupItems.find(g => g.key === selectedGroupKey);
return grp ? grp.bundleIds.includes(b.id) : true;
}).sort((a,b) => {
const ao = Number(((a.extra as Record<string, unknown>)||{}).split_order) || 0;
const bo = Number(((b.extra as Record<string, unknown>)||{}).split_order) || 0;
return ao - bo;
}).map(b => {
const ex = (b.extra as Record<string, unknown>) || {};
const splitOrder = Number(ex.split_order ?? NaN);
const heading = ex.split_heading as string | undefined;
const pipeline = (ex.pipeline as string) || (b.type === 'docling_vlm' ? 'vlm' : 'standard');
const base = heading ? `${heading}${Number.isFinite(splitOrder) ? ` (#${splitOrder})` : ''}` : (new Date(b.created_at || '').toLocaleString() || b.id);
return (<MenuItem key={b.id} value={b.id}>{`${base} [${pipeline}]`}</MenuItem>);
})}
</Select>
)}
</>
)}
<Select size="small" value={profile} onChange={(e: SelectChangeEvent<Profile>) => setProfile(e.target.value as Profile)}>
<MenuItem value="default">Default</MenuItem>
<MenuItem value="simple">Simple</MenuItem>
<MenuItem value="aggressive">Aggressive</MenuItem>
</Select>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Pipeline</Typography>
<Select size="small" value={pipeline} onChange={(e: SelectChangeEvent<Pipeline>) => setPipeline(e.target.value as Pipeline)}>
<MenuItem value="standard">Standard</MenuItem>
<MenuItem value="vlm">VLM</MenuItem>
<MenuItem value="asr">ASR</MenuItem>
</Select>
{pipeline === 'vlm' && (
<>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>VLM configuration</Typography>
<Select size="small" value={vlmMode} onChange={(e: SelectChangeEvent<VlmMode>) => setVlmMode(e.target.value as VlmMode)}>
<MenuItem value="preset">Preset</MenuItem>
<MenuItem value="local">Local (JSON)</MenuItem>
<MenuItem value="api">API (JSON)</MenuItem>
</Select>
{vlmMode === 'preset' && (
<Select size="small" value={vlmPreset} onChange={(e) => setVlmPreset(e.target.value as string)}>
<MenuItem value="smoldocling">smoldocling</MenuItem>
<MenuItem value="smoldocling_vllm">smoldocling_vllm</MenuItem>
<MenuItem value="granite_vision">granite_vision</MenuItem>
<MenuItem value="granite_vision_vllm">granite_vision_vllm</MenuItem>
<MenuItem value="granite_vision_ollama">granite_vision_ollama</MenuItem>
<MenuItem value="got_ocr_2">got_ocr_2</MenuItem>
</Select>
)}
{vlmMode === 'local' && (
<TextField size="small" label="VLM Local JSON" placeholder='{"repo_id":"..."}' value={vlmLocalJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmLocalJson(e.target.value)} multiline minRows={2} />
)}
{vlmMode === 'api' && (
<>
<Select size="small" value={vlmProvider} onChange={(e: SelectChangeEvent<VlmProvider>) => setVlmProvider(e.target.value as VlmProvider)}>
<MenuItem value="">Custom JSON</MenuItem>
<MenuItem value="ollama">Ollama</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
</Select>
{vlmProvider === 'ollama' && (
<>
<TextField size="small" label="Ollama Base URL" placeholder="http://localhost:11434" value={vlmProviderBaseUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmProviderBaseUrl(e.target.value)} />
<Select size="small" value={vlmProviderModel} onOpen={async () => {
try {
const base = vlmProviderBaseUrl || (import.meta.env.VITE_OLLAMA_BASE_URL || 'http://localhost:11434');
const resp = await fetch(`${base.replace(/\/$/, '')}/api/tags`);
if (resp.ok) {
const data = await resp.json();
const models = Array.isArray(data.models) ? (data.models as Array<{ model?: string; name?: string }>).map((m) => m.model || m.name || '').filter(Boolean) : [];
setOllamaModels(models);
}
} catch (_e) { /* no-op */ }
}} onChange={(e) => setVlmProviderModel(e.target.value as string)}>
{ollamaModels.map(m => (<MenuItem key={m} value={m}>{m}</MenuItem>))}
</Select>
</>
)}
{vlmProvider === 'openai' && (
<>
<TextField size="small" label="OpenAI Base URL (optional)" placeholder="https://api.openai.com/v1" value={vlmProviderBaseUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmProviderBaseUrl(e.target.value)} />
<Select size="small" value={vlmProviderModel} onChange={(e) => setVlmProviderModel(e.target.value as string)}>
<MenuItem value="gpt-4o-mini">gpt-4o-mini</MenuItem>
<MenuItem value="gpt-4o">gpt-4o</MenuItem>
<MenuItem value="gpt-4.1-mini">gpt-4.1-mini</MenuItem>
</Select>
</>
)}
{vlmProvider === '' && (
<TextField size="small" label="VLM API JSON" placeholder='{"provider":"ollama","base_url":"http://...","model":"..."}' value={vlmApiJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmApiJson(e.target.value)} multiline minRows={2} />
)}
</>
)}
</>
)}
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>PDF Backend</Typography>
<Select size="small" value={pdfBackend} onChange={(e: SelectChangeEvent<PdfBackend>) => setPdfBackend(e.target.value as PdfBackend)}>
<MenuItem value="dlparse_v4">dlparse_v4 (default)</MenuItem>
<MenuItem value="pypdfium2">pypdfium2</MenuItem>
<MenuItem value="dlparse_v1">dlparse_v1</MenuItem>
<MenuItem value="dlparse_v2">dlparse_v2</MenuItem>
</Select>
<FormControlLabel control={<Switch checked={doOCR} onChange={(e) => setDoOCR(e.target.checked)} />} label="OCR" />
<FormControlLabel control={<Switch checked={forceOCR} onChange={(e) => setForceOCR(e.target.checked)} />} label="Force OCR" />
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Table Mode</Typography>
<Select size="small" value={tableMode} onChange={(e: SelectChangeEvent<TableMode>) => setTableMode(e.target.value as TableMode)}>
<MenuItem value="fast">Fast</MenuItem>
<MenuItem value="accurate">Accurate</MenuItem>
</Select>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Section</Typography>
<Select size="small" value={selectedSectionId} onChange={(e: SelectChangeEvent<string>) => setSelectedSectionId(e.target.value as string)}>
<MenuItem value="full">{autoSplit ? 'Full document (auto split)' : 'Full document'}</MenuItem>
{splitSections.map(sec => (
<MenuItem key={sec.id} value={sec.id}>{sec.title ? `${sec.title} (${sec.start}-${sec.end})` : `Pages ${sec.start}-${sec.end}`}</MenuItem>
))}
</Select>
<FormControlLabel control={<Switch checked={doPicClass} onChange={(e) => setDoPicClass(e.target.checked)} />} label="Picture classification" />
<FormControlLabel control={<Switch checked={doPicDesc} onChange={(e) => setDoPicDesc(e.target.checked)} />} label="Picture description" />
{doPicDesc && (
<>
<TextField size="small" label="Description prompt" value={picDescPrompt} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPicDescPrompt(e.target.value)} />
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Picture description configuration</Typography>
<Select size="small" value={picDescMode} onChange={(e: SelectChangeEvent<PicDescMode>) => setPicDescMode(e.target.value as PicDescMode)}>
<MenuItem value="local">Local (JSON)</MenuItem>
<MenuItem value="api">API (JSON)</MenuItem>
</Select>
{picDescMode === 'local' && (
<TextField size="small" label="Picture Description Local JSON" placeholder='{"repo_id":"..."}' value={picDescLocalJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPicDescLocalJson(e.target.value)} multiline minRows={2} />
)}
{picDescMode === 'api' && (
<TextField size="small" label="Picture Description API JSON" placeholder='{"base_url":"..."}' value={picDescApiJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPicDescApiJson(e.target.value)} multiline minRows={2} />
)}
</>
)}
<FormControlLabel control={<Switch checked={doFormula} onChange={(e) => setDoFormula(e.target.checked)} />} label="Formula enrichment" />
<FormControlLabel control={<Switch checked={doCode} onChange={(e) => setDoCode(e.target.checked)} />} label="Code enrichment" />
<FormControlLabel control={<Switch checked={tableCellMatching} onChange={(e) => setTableCellMatching(e.target.checked)} />} label="Table cell matching" />
{/* Outputs are always all formats for canonical bundles; UI omitted */}
<Button variant="contained" disabled={busy || !validFileId} onClick={async () => {
try {
setBusy(true);
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const body: CanonicalDoclingRequest = {
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
config: {
pipeline,
pdf_backend: pdfBackend,
do_ocr: doOCR,
force_ocr: forceOCR,
table_mode: tableMode,
do_picture_classification: doPicClass,
do_picture_description: doPicDesc,
picture_description_prompt: doPicDesc ? picDescPrompt : undefined,
target_type: 'zip',
image_export_mode: 'referenced',
table_cell_matching: tableCellMatching
},
threshold: splitThreshold
};
body.config.to_formats = ['json','html','text','md','doctags'];
body.config.do_formula_enrichment = doFormula;
body.config.do_code_enrichment = doCode;
// Apply selected section as custom range
const sel = selectedSectionId !== 'full' ? splitSections.find(s => s.id === selectedSectionId) : undefined;
if (sel) {
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).custom_range = [sel.start, sel.end];
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).custom_label = sel.title || `Pages ${sel.start}-${sel.end}`;
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).selected_section_id = sel.id;
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).selected_section_title = sel.title || '';
}
// If full and autoSplit, ensure threshold present
if (selectedSectionId === 'full' && autoSplit) {
(body as unknown as { threshold: number }).threshold = splitThreshold;
}
// Picture description mutually exclusive config
if (doPicDesc) {
if (picDescMode === 'local' && picDescLocalJson.trim()) {
body.config.picture_description_local = picDescLocalJson.trim();
body.config.picture_description_api = undefined;
} else if (picDescMode === 'api' && picDescApiJson.trim()) {
body.config.picture_description_api = picDescApiJson.trim();
body.config.picture_description_local = undefined;
} else {
body.config.picture_description_local = undefined;
body.config.picture_description_api = undefined;
}
} else {
body.config.picture_description_local = undefined;
body.config.picture_description_api = undefined;
}
// VLM mutually exclusive config + provider presets
if (pipeline === 'vlm') {
if (vlmMode === 'preset') {
body.config.vlm_pipeline_model = vlmPreset;
body.config.vlm_pipeline_model_local = undefined;
body.config.vlm_pipeline_model_api = undefined;
} else if (vlmMode === 'local' && vlmLocalJson.trim()) {
body.config.vlm_pipeline_model_local = vlmLocalJson.trim();
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_api = undefined;
} else if (vlmMode === 'api') {
if (vlmProvider) {
(body.config as unknown as { vlm_provider: string; vlm_provider_model: string; vlm_provider_base_url: string }).vlm_provider = vlmProvider;
(body.config as unknown as { vlm_provider: string; vlm_provider_model: string; vlm_provider_base_url: string }).vlm_provider_model = vlmProviderModel.trim();
(body.config as unknown as { vlm_provider: string; vlm_provider_model: string; vlm_provider_base_url: string }).vlm_provider_base_url = vlmProviderBaseUrl.trim();
body.config.vlm_pipeline_model_api = undefined;
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
} else if (vlmApiJson.trim()) {
body.config.vlm_pipeline_model_api = vlmApiJson.trim();
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
} else {
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
body.config.vlm_pipeline_model_api = undefined;
}
}
} else {
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
body.config.vlm_pipeline_model_api = undefined;
}
const resp = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/canonical-docling`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await resp.json();
console.log('canonical-docling:', data);
// Refresh bundles list
try {
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) {
const arts: Artefact[] = await res.json();
const list = arts.filter(a => a.type === 'docling_standard' || a.type === 'docling_vlm' || a.type === 'vlm_section_page_bundle' || a.type === 'docling_bundle' || a.type === 'docling_bundle_split' || a.type === 'docling_bundle_split_pages' || a.type === 'canonical_docling_json')
.sort((a, b) => {
// Sort by creation time, newest first
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return tb - ta;
});
setBundles(list);
if (list.length && !currentBundle) setCurrentBundle(list[0].id);
}
} catch (_err: unknown) { void 0; }
} finally {
setBusy(false);
}
}}>Generate Doclings</Button>
</Box>
</Box>
</Box>
);
};
export default CCDocumentIntelligence;

View File

@ -0,0 +1,570 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
Box, CircularProgress, IconButton, Typography, Collapse, Chip,
List, ListItem, ListItemButton, ListItemIcon, ListItemText
} from '@mui/material';
import {
ExpandMore, ChevronRight, Description, Check, Schedule,
Visibility, Psychology, Home as OverviewIcon
} from '@mui/icons-material';
import { supabase } from '../../../supabaseClient';
// Types
type PageImagesManifest = {
version: number;
file_id: string;
page_count: number;
bucket?: string;
base_dir?: string;
page_images: Array<{
page: number;
full_image_path: string;
thumbnail_path: string;
full_dimensions?: { width: number; height: number };
thumbnail_dimensions?: { width: number; height: number };
}>
};
type OutlineSection = {
id: string;
title: string;
level: number;
start_page: number;
end_page: number;
parent_id?: string | null;
children?: string[];
};
type ProcessingStatus = {
tika: boolean;
frontmatter: boolean;
structure_analysis: boolean;
split_map: boolean;
page_images: boolean;
docling_ocr: boolean;
docling_no_ocr: boolean;
docling_vlm: boolean;
};
type SectionNode = {
sec: OutlineSection;
children: SectionNode[];
};
interface CCEnhancedFilePanelProps {
fileId: string;
selectedPage: number;
onSelectPage: (page: number) => void;
currentSection?: { start: number; end: number };
}
export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
fileId, selectedPage, onSelectPage, currentSection
}) => {
// State
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
const [outline, setOutline] = useState<OutlineSection[]>([]);
const [processingStatus, setProcessingStatus] = useState<ProcessingStatus>({
tika: false, frontmatter: false, structure_analysis: false, split_map: false,
page_images: false, docling_ocr: false, docling_no_ocr: false, docling_vlm: false
});
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [selectedView, setSelectedView] = useState<'overview' | 'structure' | 'thumbnails'>('structure');
const [thumbUrls] = useState<Map<number, string>>(() => new Map());
// Refs for scroll syncing
const thumbnailsRef = useRef<HTMLDivElement>(null);
const API_BASE = useMemo(() =>
import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'),
[]
);
// Load data
useEffect(() => {
const loadData = async () => {
if (!fileId) return;
setLoading(true);
setError(null);
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
// Load page images manifest
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (manifestRes.ok) {
const m: PageImagesManifest = await manifestRes.json();
setManifest(m);
}
// Load artefacts to determine processing status and structure
const artefactsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (artefactsRes.ok) {
const artefacts: Array<{
id: string;
type: string;
status: string;
extra?: {
config?: {
do_ocr?: boolean;
};
};
}> = await artefactsRes.json();
// Determine processing status
const status: ProcessingStatus = {
tika: artefacts.some((a) => a.type === 'tika_json' && a.status === 'completed'),
frontmatter: artefacts.some((a) => a.type === 'docling_frontmatter_json' && a.status === 'completed'),
structure_analysis: artefacts.some((a) => a.type === 'document_outline_hierarchy' && a.status === 'completed'),
split_map: artefacts.some((a) => a.type === 'split_map_json' && a.status === 'completed'),
page_images: artefacts.some((a) => a.type === 'page_images' && a.status === 'completed'),
docling_ocr: artefacts.some((a) => a.type === 'docling_standard' && (a.extra?.config?.do_ocr === true) && a.status === 'completed'),
docling_no_ocr: artefacts.some((a) => a.type === 'docling_standard' && (a.extra?.config?.do_ocr === false) && a.status === 'completed'),
docling_vlm: artefacts.some((a) => a.type === 'docling_vlm' && a.status === 'completed')
};
setProcessingStatus(status);
// Load document outline/structure
const outlineArt = artefacts.find((a) => a.type === 'document_outline_hierarchy' && a.status === 'completed');
if (outlineArt) {
const structureRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (structureRes.ok) {
const structureData = await structureRes.json();
const sections = (structureData.sections || []) as OutlineSection[];
setOutline(sections);
}
}
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load file data');
} finally {
setLoading(false);
}
};
loadData();
}, [fileId, API_BASE]);
// Build hierarchical section tree
const sectionTree = useMemo(() => {
const buildTree = (sections: OutlineSection[]): SectionNode[] => {
const roots: SectionNode[] = [];
const stack: SectionNode[] = [];
const sorted = [...sections].sort((a, b) => a.start_page - b.start_page);
for (const sec of sorted) {
const level = Math.max(1, Number(sec.level || 1));
const node: SectionNode = { sec, children: [] };
// Maintain proper hierarchy based on level
while (stack.length && stack.length >= level) stack.pop();
const parent = stack[stack.length - 1];
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
stack.push(node);
}
return roots;
};
return buildTree(outline);
}, [outline]);
// Thumbnail fetching with lazy loading
const fetchThumbnail = useCallback(async (page: number): Promise<string | undefined> => {
if (!manifest) return undefined;
const cached = thumbUrls.get(page);
if (cached) return cached;
const pageIndex = Math.max(0, Math.min((manifest.page_count || 1) - 1, page - 1));
const pageInfo = manifest.page_images[pageIndex];
if (!pageInfo) return undefined;
try {
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!response.ok) return undefined;
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
thumbUrls.set(page, objectUrl);
return objectUrl;
} catch {
return undefined;
}
}, [manifest, API_BASE, thumbUrls]);
// Render overview panel
const renderOverview = () => (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Processing Status</Typography>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, fontWeight: 600 }}>Phase 1: Structure Discovery</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<StatusItem label="Tika Metadata" status={processingStatus.tika} />
<StatusItem label="Document Frontmatter" status={processingStatus.frontmatter} />
<StatusItem label="Structure Analysis" status={processingStatus.structure_analysis} />
<StatusItem label="Split Map" status={processingStatus.split_map} />
<StatusItem label="Page Images" status={processingStatus.page_images} />
</Box>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, fontWeight: 600 }}>Phase 2: Content Processing</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<StatusItem label="OCR Processing" status={processingStatus.docling_ocr} icon={<Visibility />} />
<StatusItem label="No-OCR Processing" status={processingStatus.docling_no_ocr} icon={<Description />} />
<StatusItem label="VLM Analysis" status={processingStatus.docling_vlm} icon={<Psychology />} />
</Box>
{manifest && (
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Document Info</Typography>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>
{manifest.page_count} pages
</Typography>
{outline.length > 0 && (
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>
{outline.length} sections identified
</Typography>
)}
</Box>
)}
</Box>
);
// Render document structure tree
const renderStructureTree = () => (
<Box sx={{ flex: 1, overflow: 'auto' }}>
{sectionTree.length === 0 ? (
<Box sx={{ p: 2, color: 'var(--color-text-2)', textAlign: 'center' }}>
<Typography variant="body2">
{processingStatus.structure_analysis ? 'No document structure detected' : 'Structure analysis pending...'}
</Typography>
</Box>
) : (
<List dense sx={{ py: 0 }}>
{sectionTree.map((node) => (
<SectionTreeItem
key={node.sec.id}
node={node}
level={1}
selectedPage={selectedPage}
onSelectPage={onSelectPage}
collapsed={collapsed}
onToggleCollapse={(id) => {
const newCollapsed = new Set(collapsed);
if (newCollapsed.has(id)) {
newCollapsed.delete(id);
} else {
newCollapsed.add(id);
}
setCollapsed(newCollapsed);
}}
processingStatus={processingStatus}
/>
))}
</List>
)}
</Box>
);
// Render page thumbnails with lazy loading
const renderThumbnails = () => {
if (!manifest) return null;
const pages = Array.from({ length: manifest.page_count }, (_, i) => i + 1);
return (
<Box
ref={thumbnailsRef}
sx={{
flex: 1,
overflow: 'auto',
p: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: 1,
alignContent: 'start'
}}
>
{pages.map((page) => (
<LazyThumbnail
key={page}
page={page}
isSelected={page === selectedPage}
isInSection={currentSection ? page >= currentSection.start && page <= currentSection.end : false}
fetchThumbnail={fetchThumbnail}
onSelect={onSelectPage}
/>
))}
</Box>
);
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress size={24} />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2, color: 'var(--color-error)' }}>
<Typography variant="body2">{error}</Typography>
</Box>
);
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Header with navigation tabs */}
<Box sx={{
borderBottom: '1px solid var(--color-divider)',
bgcolor: 'var(--color-panel)',
p: 1
}}>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
size="small"
onClick={() => setSelectedView('overview')}
color={selectedView === 'overview' ? 'primary' : 'default'}
title="Processing Overview"
>
<OverviewIcon />
</IconButton>
<IconButton
size="small"
onClick={() => setSelectedView('structure')}
color={selectedView === 'structure' ? 'primary' : 'default'}
title="Document Structure"
>
<Description />
</IconButton>
<IconButton
size="small"
onClick={() => setSelectedView('thumbnails')}
color={selectedView === 'thumbnails' ? 'primary' : 'default'}
title="Page Thumbnails"
>
<Visibility />
</IconButton>
</Box>
</Box>
{/* Content based on selected view */}
{selectedView === 'overview' && renderOverview()}
{selectedView === 'structure' && renderStructureTree()}
{selectedView === 'thumbnails' && renderThumbnails()}
</Box>
);
};
// Status indicator component
const StatusItem: React.FC<{
label: string;
status: boolean;
icon?: React.ReactNode;
}> = ({ label, status, icon }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{icon || <Description fontSize="small" />}
<Typography variant="body2" sx={{ flex: 1 }}>
{label}
</Typography>
{status ? (
<Check fontSize="small" color="success" />
) : (
<Schedule fontSize="small" sx={{ color: 'var(--color-text-3)' }} />
)}
</Box>
);
// Section tree item component
const SectionTreeItem: React.FC<{
node: SectionNode;
level: number;
selectedPage: number;
onSelectPage: (page: number) => void;
collapsed: Set<string>;
onToggleCollapse: (id: string) => void;
processingStatus: ProcessingStatus;
}> = ({ node, level, selectedPage, onSelectPage, collapsed, onToggleCollapse, processingStatus }) => {
const isCollapsed = collapsed.has(node.sec.id);
const hasChildren = node.children.length > 0;
const isCurrentSection = selectedPage >= node.sec.start_page && selectedPage <= node.sec.end_page;
return (
<>
<ListItem disablePadding>
<ListItemButton
sx={{
pl: level * 2,
py: 0.5,
bgcolor: isCurrentSection ? 'var(--color-selected)' : 'transparent',
'&:hover': { bgcolor: 'var(--color-hover)' }
}}
onClick={() => onSelectPage(node.sec.start_page)}
>
<ListItemIcon sx={{ minWidth: 24 }}>
{hasChildren ? (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onToggleCollapse(node.sec.id);
}}
>
{isCollapsed ? <ChevronRight /> : <ExpandMore />}
</IconButton>
) : (
<Box sx={{ width: 24 }} />
)}
</ListItemIcon>
<ListItemText
primary={node.sec.title || `Section ${node.sec.start_page}`}
secondary={`Pages ${node.sec.start_page}-${node.sec.end_page}`}
primaryTypographyProps={{
variant: 'body2',
sx: { fontWeight: isCurrentSection ? 600 : 400 }
}}
secondaryTypographyProps={{ variant: 'caption' }}
/>
{/* Processing indicators */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{processingStatus.docling_ocr && <Chip size="small" label="OCR" sx={{ fontSize: '10px' }} />}
{processingStatus.docling_no_ocr && <Chip size="small" label="Text" sx={{ fontSize: '10px' }} />}
{processingStatus.docling_vlm && <Chip size="small" label="VLM" sx={{ fontSize: '10px' }} />}
</Box>
</ListItemButton>
</ListItem>
{hasChildren && !isCollapsed && (
<Collapse in={!isCollapsed} timeout="auto">
{node.children.map((child) => (
<SectionTreeItem
key={child.sec.id}
node={child}
level={level + 1}
selectedPage={selectedPage}
onSelectPage={onSelectPage}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
processingStatus={processingStatus}
/>
))}
</Collapse>
)}
</>
);
};
// Lazy loading thumbnail component
const LazyThumbnail: React.FC<{
page: number;
isSelected: boolean;
isInSection: boolean;
fetchThumbnail: (page: number) => Promise<string | undefined>;
onSelect: (page: number) => void;
}> = ({ page, isSelected, isInSection, fetchThumbnail, onSelect }) => {
const [src, setSrc] = useState<string | undefined>(undefined);
const [isVisible, setIsVisible] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
// Intersection observer for lazy loading
useEffect(() => {
if (!imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1, rootMargin: '50px' }
);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
// Load thumbnail when visible
useEffect(() => {
if (isVisible && !src) {
fetchThumbnail(page).then(setSrc);
}
}, [isVisible, page, fetchThumbnail, src]);
return (
<Box
ref={imgRef}
onClick={() => onSelect(page)}
sx={{
aspectRatio: '3/4',
border: isSelected ? '2px solid var(--color-primary)' : '1px solid var(--color-divider)',
borderRadius: 1,
overflow: 'hidden',
cursor: 'pointer',
position: 'relative',
bgcolor: isInSection ? 'var(--color-selected)' : 'var(--color-panel)',
'&:hover': { borderColor: 'var(--color-primary-light)' },
transition: 'border-color 0.2s'
}}
>
{src ? (
<img
src={src}
alt={`Page ${page}`}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block'
}}
/>
) : isVisible ? (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%'
}}>
<CircularProgress size={16} />
</Box>
) : null}
<Box
sx={{
position: 'absolute',
bottom: 2,
right: 2,
bgcolor: 'rgba(0,0,0,0.7)',
color: 'white',
px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontSize: '11px',
lineHeight: 1
}}
>
{page}
</Box>
</Box>
);
};
export default CCEnhancedFilePanel;

View File

@ -0,0 +1,363 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, IconButton, MenuItem, Select, TextField, Typography } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import { supabase } from '../../../supabaseClient';
type PageImagesManifest = {
version: number;
file_id: string;
page_count: number;
bucket?: string;
base_dir?: string;
page_images: Array<{
page: number;
full_image_path: string;
thumbnail_path: string;
full_dimensions?: { width: number; height: number };
thumbnail_dimensions?: { width: number; height: number };
}>
};
type OutlineSection = {
id: string;
title: string;
level: number;
start_page: number;
end_page: number;
parent_id?: string | null;
children?: string[];
};
type Outline = {
sections: OutlineSection[];
};
type QueueTaskBrief = {
id: string;
service?: string;
task_type?: string;
status?: string;
priority?: string;
created_at?: number;
scheduled_at?: number;
depends_on?: string[];
};
type FileTasksResponse = { file_id: string; count: number; tasks: QueueTaskBrief[] } | { error: string };
export const CCFileDetailPanel: React.FC<{
fileId: string;
selectedPage: number;
onSelectPage: (p: number) => void;
}> = ({ fileId, selectedPage, onSelectPage }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
const [outline, setOutline] = useState<Outline | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
// outline only used for grouping thumbnails
const [thumbUrls] = useState<Map<number, string>>(() => new Map());
const [showAdmin, setShowAdmin] = useState(false);
const [adminData, setAdminData] = useState<FileTasksResponse | null>(null);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
useEffect(() => {
const run = async () => {
if (!fileId) return;
setLoading(true);
setError(null);
try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!mRes.ok) throw new Error(await mRes.text());
const m: PageImagesManifest = await mRes.json();
setManifest(m);
// Try to load outline structure artefact (for grouping only)
try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (artsRes.ok) {
const arts: Array<{ id: string; type: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (outlineArt) {
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (jsonRes.ok) {
const outJson = await jsonRes.json();
const secs = (outJson.sections || []) as OutlineSection[];
setOutline({ sections: secs });
}
}
}
} catch {
// ignore
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load manifest');
} finally {
setLoading(false);
}
};
run();
}, [fileId, API_BASE]);
const fetchThumb = useCallback(async (page: number): Promise<string | undefined> => {
if (!manifest) return undefined;
const cached = thumbUrls.get(page);
if (cached) return cached;
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, page - 1));
const pg = manifest.page_images[idx];
if (!pg) return undefined;
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) return undefined;
const blob = await resp.blob();
const objUrl = URL.createObjectURL(blob);
thumbUrls.set(page, objUrl);
return objUrl;
}, [manifest, API_BASE, thumbUrls]);
useEffect(() => {
if (!manifest) return;
// Prefetch first few thumbs
const prefetch = async () => {
const limit = Math.min(10, manifest.page_count || 0);
for (let p = 1; p <= limit; p++) {
// eslint-disable-next-line no-await-in-loop
await fetchThumb(p);
}
};
prefetch();
}, [manifest, fetchThumb]);
if (loading) return <Box sx={{ p: 2 }}><CircularProgress size={18} /></Box>;
if (error) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error}</Box>;
if (!manifest) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>No page images manifest.</Box>;
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', fontWeight: 600, flexShrink: 0, bgcolor: 'var(--color-panel)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>File Details
<IconButton size="small" onClick={async () => {
try {
setShowAdmin(true);
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
const data = await res.json();
setAdminData(data);
} catch (e) {
setAdminData({ error: (e as Error)?.message || 'Failed to load' });
}
}} title="Queue debug (admin)"><AdminPanelSettingsIcon fontSize="inherit" /></IconButton>
</Box>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" onClick={() => onSelectPage(Math.max(1, selectedPage - 1))}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
<TextField
size="small"
value={selectedPage}
onChange={(e) => onSelectPage(Number(e.target.value) || 1)}
sx={{ flexShrink: 0 }}
InputProps={{ sx: { width: 64, '& input': { textAlign: 'center', padding: '6px' } } }}
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', maxLength: 4 }}
/>
<IconButton size="small" onClick={() => onSelectPage(Math.min(manifest.page_count, selectedPage + 1))}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>/ {manifest.page_count}</Typography>
</Box>
</Box>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
<Select size="small" value={getCurrentSectionStart(outline, selectedPage)} onChange={(e) => onSelectPage(Number(e.target.value))} displayEmpty sx={{ width: '100%', flexShrink: 0, '& .MuiSelect-select': { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }}>
{!outline || outline.sections.length === 0 ? (
<MenuItem value={selectedPage} disabled>No outline</MenuItem>
) : (
outline.sections.sort((a, b) => a.start_page - b.start_page).map((sec) => (
<MenuItem key={sec.id} value={sec.start_page}>{sec.title.length > 60 ? `${sec.title.slice(0,60)}` : sec.title} (p{sec.start_page})</MenuItem>
))
)}
</Select>
{outline && outline.sections.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
</Box>
)}
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', display: 'block', p: 1 }}>
{showAdmin && (
<Box sx={{ mb: 1, p: 1, border: '1px dashed var(--color-divider)', borderRadius: 1, bgcolor: 'var(--color-panel)' }}>
<Typography variant="caption" sx={{ color: 'var(--color-text-3)' }}>Queue tasks for this file</Typography>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, margin: 0 }}>{JSON.stringify(adminData, null, 2)}</pre>
</Box>
)}
{outline && outline.sections.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)', fontWeight: 600 }}>Sections</Typography>
<Box>
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
</Box>
</Box>
)}
{renderGroupedTiles(manifest, outline, fetchThumb, selectedPage, onSelectPage, collapsed, (id) => setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
}))}
</Box>
</Box>
);
};
type SectionNode = { sec: OutlineSection; children: SectionNode[] };
const buildSectionTree = (sections: OutlineSection[]): SectionNode[] => {
const roots: SectionNode[] = [];
const stack: SectionNode[] = [];
const sorted = [...sections].sort((a, b) => a.start_page - b.start_page);
for (const s of sorted) {
const level = Math.max(1, Number(s.level || 1));
const node: SectionNode = { sec: s, children: [] };
while (stack.length && (stack.length >= level)) stack.pop();
const parent = stack[stack.length - 1];
if (parent) parent.children.push(node); else roots.push(node);
stack.push(node);
}
return roots;
};
const renderGroupedTiles = (
manifest: PageImagesManifest,
outline: Outline | null,
fetchThumb: (p: number) => Promise<string | undefined>,
selectedPage: number,
onSelectPage: (p: number) => void,
collapsed: Set<string>,
toggleCollapse: (id: string) => void
) => {
if (!outline || outline.sections.length === 0) {
// No outline: show a simple grid of page tiles
return (
<Box sx={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1 }}>
{manifest.page_images.map((pg) => (
<PageTile key={pg.page} page={pg.page} fetchSrc={() => fetchThumb(pg.page)} selected={pg.page === selectedPage} onClick={() => onSelectPage(pg.page)} />
))}
</Box>
);
}
const tree = buildSectionTree(outline.sections);
return tree.map((node) => (
<SectionTile
key={node.sec.id}
node={node}
manifest={manifest}
fetchThumb={fetchThumb}
selectedPage={selectedPage}
onSelectPage={onSelectPage}
collapsed={collapsed}
toggleCollapse={toggleCollapse}
level={Math.max(1, Number(node.sec.level || 1))}
/>
));
};
// OutlineTree UI has been moved to top navigation; grouping-by-section thumbnails remain below.
const SectionTile: React.FC<{
node: SectionNode;
manifest: PageImagesManifest;
fetchThumb: (p: number) => Promise<string | undefined>;
selectedPage: number;
onSelectPage: (p: number) => void;
collapsed: Set<string>;
toggleCollapse: (id: string) => void;
level: number;
}> = ({ node, manifest, fetchThumb, selectedPage, onSelectPage, collapsed, toggleCollapse, level }) => {
const s = node.sec;
const ml = (level - 1) * 1;
const isCollapsed = collapsed.has(s.id);
return (
<Box sx={{ width: '100%', mt: 1, ml, border: '1px solid var(--color-divider)', borderRadius: 1, overflow: 'hidden', bgcolor: 'var(--color-panel)' }}>
<Box sx={{ px: 1, py: 0.75, fontWeight: 700, color: 'var(--color-text-1)', display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer', background:
level === 1 ? 'rgba(0,0,0,0.03)' : level === 2 ? 'rgba(0,0,0,0.02)' : 'transparent',
borderBottom: '1px solid var(--color-divider)'
}}>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); toggleCollapse(s.id); }}>
{isCollapsed ? <ChevronRightIcon fontSize="inherit" /> : <ExpandMoreIcon fontSize="inherit" />}
</IconButton>
<Box onClick={() => onSelectPage(Math.max(1, s.start_page))}>
<Typography component="span" sx={{ color: 'var(--color-text-1)' }}>{s.title}</Typography>
</Box>
<Typography component="span" sx={{ fontSize: 12, color: 'var(--color-text-3)', ml: 1 }}>({s.start_page}{s.end_page})</Typography>
</Box>
{!isCollapsed && (
<Box sx={{ p: 1 }}>
<Box sx={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1 }}>
{Array.from({ length: Math.max(0, Math.min(s.end_page, manifest.page_count) - s.start_page + 1) }).map((_, i) => {
const p = s.start_page + i;
return (
<PageTile key={`p-${p}`} page={p} fetchSrc={() => fetchThumb(p)} selected={p === selectedPage} onClick={() => onSelectPage(p)} />
);
})}
</Box>
</Box>
)}
{!isCollapsed && node.children.length > 0 && (
<Box sx={{ p: 1 }}>
{node.children.map((child) => (
<SectionTile key={child.sec.id} node={child} manifest={manifest} fetchThumb={fetchThumb} selectedPage={selectedPage} onSelectPage={onSelectPage} collapsed={collapsed} toggleCollapse={toggleCollapse} level={Math.max(1, Number(child.sec.level || level + 1))} />
))}
</Box>
)}
</Box>
);
};
function getCurrentSectionStart(outline: Outline | null, selectedPage: number): number {
if (!outline || outline.sections.length === 0) return selectedPage;
const secs = [...outline.sections].sort((a, b) => a.start_page - b.start_page);
for (let i = 0; i < secs.length; i++) {
const s = secs[i];
const end = s.end_page ?? (i + 1 < secs.length ? secs[i + 1].start_page - 1 : Number.MAX_SAFE_INTEGER);
if (selectedPage >= s.start_page && selectedPage <= end) return s.start_page;
}
return selectedPage;
}
function collectAllIds(sections: OutlineSection[]): string[] {
const ids: string[] = [];
for (const s of sections) ids.push(s.id);
return ids;
}
const PageTile: React.FC<{
page: number;
selected: boolean;
fetchSrc: () => Promise<string | undefined>;
onClick: () => void;
}> = ({ page, selected, fetchSrc, onClick }) => {
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => { (async () => setSrc(await fetchSrc()))(); }, [fetchSrc, page]);
return (
<Box onClick={onClick} sx={{ width: '100%', minWidth: 0, display: 'flex', flexDirection: 'column', borderRadius: 1, overflow: 'hidden', cursor: 'pointer', border: selected ? '2px solid #1976d2' : '1px solid var(--color-divider)', boxShadow: selected ? '0 0 0 2px rgba(25,118,210,0.15) inset' : 'none', bgcolor: 'rgba(0,0,0,0.02)' }}>
<Box sx={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(0,0,0,0.05)' }}>
{src ? <img src={src} alt={`p${page}`} style={{ maxHeight: '100%', maxWidth: '100%', display: 'block' }} /> : <CircularProgress size={16} />}
<Box sx={{ position: 'absolute', top: 6, right: 6, bgcolor: 'rgba(0,0,0,0.6)', color: '#fff', borderRadius: 1, px: 0.5, fontSize: 11, lineHeight: '16px' }}>{page}</Box>
</Box>
</Box>
);
};
// Replaced old ThumbRow with PageTile-based grid tiles
export default CCFileDetailPanel;

View File

@ -123,37 +123,73 @@ export default function SinglePlayerPage() {
logger.debug('single-player-page', '✅ TLStore created');
// 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore);
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
tldraw_snapshot: context.node.tldraw_snapshot,
user_type: user.user_type,
username: user.username
});
const nodeStoragePath = getNodeStoragePath(context.node);
if (nodeStoragePath) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
node: context.node,
node_storage_path: nodeStoragePath,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.tldraw_snapshot,
user.user_db_name,
newStore,
setLoadingState
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath,
user.user_db_name,
newStore,
setLoadingState,
undefined, // sharedStore
editorRef.current || undefined // editor
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
node: context.node
});
}
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 4. Set up auto-save
// 4. Set up auto-save with debouncing (only after initial load is complete)
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
let isAutoSaving = false;
newStore.listen(() => {
if (snapshotServiceRef.current && context.node) {
logger.debug('single-player-page', '💾 Auto-saving changes');
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Auto-save failed', error);
});
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) {
// Skip if already saving
if (isAutoSaving) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
return;
}
// Clear existing timeout
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
// Debounce auto-save to prevent excessive saves
autoSaveTimeout = setTimeout(async () => {
if (isAutoSaving) return; // Double-check
isAutoSaving = true;
try {
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
await snapshotServiceRef.current?.forceSaveCurrentNode();
} catch (error) {
logger.error('single-player-page', '❌ Auto-save failed', error);
} finally {
isAutoSaving = false;
}
}, 2000); // Increased to 2 seconds debounce
} else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet');
}
});
@ -185,12 +221,43 @@ export default function SinglePlayerPage() {
};
initializeStoreAndSnapshot();
}, [isEditorReady, user, context.node, editorRef.current]);
}, [isEditorReady, user, context.node]);
// Handle initial node placement
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
hasNode: !!context.node,
hasEditor: !!editorRef.current,
hasStore: !!store,
isInitialLoad
});
return;
}
// Debug: Log the actual node structure
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
node: context.node,
nodeKeys: Object.keys(context.node),
hasId: !!context.node.id,
hasStoragePath: !!context.node.node_storage_path,
hasData: !!context.node.data,
dataKeys: context.node.data ? Object.keys(context.node.data) : null
});
// Validate that the node has required properties
const nodeStoragePath = getNodeStoragePath(context.node);
if (!context.node.id || !nodeStoragePath) {
logger.error('single-player-page', '❌ Node missing required properties', {
nodeId: context.node.id,
hasStoragePath: !!nodeStoragePath,
node: context.node
});
setLoadingState({
status: 'error',
error: 'Node is missing required information'
});
return;
}
@ -231,7 +298,7 @@ export default function SinglePlayerPage() {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: currentNode.id,
tldraw_snapshot: currentNode.tldraw_snapshot,
node_storage_path: currentNode.node_storage_path,
isInitialLoad
});
@ -258,7 +325,7 @@ export default function SinglePlayerPage() {
};
handleNodeChange();
}, [context.node?.id, context.history, store]);
}, [context.node, context.history, store, isInitialLoad]);
// Initialize preferences when user is available
useEffect(() => {
@ -270,7 +337,7 @@ export default function SinglePlayerPage() {
// Redirect if no user or incorrect role
useEffect(() => {
if (!user || user.user_type !== 'admin') {
if (!user || !['admin', 'email_teacher', 'school_admin', 'teacher'].includes(user.user_type || '')) {
logger.info('single-player-page', '🚪 Redirecting to home - no user or incorrect role', {
hasUser: !!user,
userType: user?.user_type
@ -467,6 +534,11 @@ export default function SinglePlayerPage() {
editorRef.current = editor;
logger.debug('single-player-page', '✅ Editor ref set');
// Update snapshot service with editor reference
if (snapshotServiceRef.current) {
snapshotServiceRef.current.setEditor(editor);
}
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id,
@ -482,33 +554,89 @@ export default function SinglePlayerPage() {
);
}
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// 1. Always fetch fresh data
const dbName = UserNeoDBService.getNodeDatabaseName(node);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
// Helper function to safely extract node_storage_path from different node structures
const getNodeStoragePath = (node: NavigationNode): string | null => {
// Try direct access first
if (node.node_storage_path) {
return node.node_storage_path;
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: fetchedData.node_data.title || node.label,
w: 500,
h: 350,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
// Try nested under data
if (node.data?.node_storage_path) {
return node.data.node_storage_path;
}
// Try other possible locations
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
return node.data.storage_path;
}
return null;
};
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// Validate the node parameter
if (!node) {
throw new Error('Node parameter is required');
}
if (!node.id) {
throw new Error('Node must have an ID');
}
const nodeStoragePath = getNodeStoragePath(node);
if (!nodeStoragePath) {
throw new Error(`Node ${node.id} is missing node_storage_path`);
}
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: node.id,
nodeType: node.type,
nodeLabel: node.label,
nodeStoragePath: nodeStoragePath
});
try {
// 1. Always fetch fresh data
// Create a temporary node object with the correct structure for the service
const normalizedNode = {
...node,
node_storage_path: nodeStoragePath
};
const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: String(fetchedData.node_data.title || node.label || ''),
w: 500,
h: 350,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
uuid_string: node.id,
node_storage_path: nodeStoragePath
};
} catch (error) {
logger.error('single-player-page', '❌ Error in loadNodeData', {
nodeId: node.id,
nodeType: node.type,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
};

View File

@ -22,7 +22,7 @@ interface Event {
subjectClass: string;
color: string;
periodCode: string;
tldraw_snapshot?: string;
node_storage_path?: string;
};
}
@ -155,12 +155,12 @@ const CalendarPage: React.FC = () => {
try {
logger.debug('calendar', 'Fetching events', {
unique_id: workerNode.nodeData.unique_id,
uuid_string: workerNode.nodeData.uuid_string,
school_db_name: workerDbName
});
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
workerNode.nodeData.unique_id,
workerNode.nodeData.uuid_string,
workerDbName || ''
);
@ -168,7 +168,7 @@ const CalendarPage: React.FC = () => {
...event,
extendedProps: {
...event.extendedProps,
tldraw_snapshot: workerNode?.nodeData?.tldraw_snapshot
node_storage_path: workerNode?.nodeData?.node_storage_path
}
}));
@ -196,11 +196,11 @@ const CalendarPage: React.FC = () => {
}, [fetchEvents]);
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot;
if (tldraw_snapshot) {
// TODO: Implement tldraw_snapshot retrieval from storage API
const node_storage_path = clickInfo.event.extendedProps?.node_storage_path;
if (node_storage_path) {
// TODO: Implement node_storage_path retrieval from storage API
// For now, we'll just log it
console.log('TLDraw snapshot:', tldraw_snapshot);
console.log('TLDraw snapshot:', node_storage_path);
}
}, []);

View File

@ -0,0 +1,96 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Button,
Container,
Grid,
Paper,
Stack,
Typography
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { useUser } from '../../contexts/UserContext';
const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { profile, loading } = useUser();
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
const emailAddress = profile?.email || authUser?.email || '';
const userType = profile?.user_type || authUser?.user_type || '';
return (
<Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={6}>
<Box>
<Typography variant="h3" component="h1" gutterBottom>
Welcome back{displayName ? `, ${displayName}` : ''}!
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
This is your starting point inside ClassroomCopilot. We keep things simple here so you
can decide what to explore next.
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Account overview
</Typography>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
Signed in as
</Typography>
<Typography variant="body1">
{emailAddress || 'No email on file'}
</Typography>
{userType && (
<Typography variant="body2" color="text.secondary">
Role: {userType}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
{loading ? 'Checking profile details...' : 'Profile ready'}
</Typography>
</Stack>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>
Quick actions
</Typography>
<Stack spacing={2}>
<Button
variant="contained"
color="primary"
onClick={() => navigate('/single-player')}
>
Open workspace
</Button>
<Button
variant="outlined"
onClick={() => navigate('/calendar')}
>
View calendar
</Button>
<Button
variant="outlined"
onClick={() => navigate('/settings')}
>
Update settings
</Button>
</Stack>
</Paper>
</Grid>
</Grid>
</Stack>
</Container>
);
};
export default DashboardPage;

View File

@ -44,11 +44,19 @@ export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
// Default to student if no user type specified
const userType = metadata.user_type || 'student';
const userDbName = DatabaseNameService.getUserPrivateDB(
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(
userType,
username
user.id
);
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
const schoolDbName = metadata.school_db_name || metadata.worker_db_name || storedSchoolDb || '';
DatabaseNameService.rememberDatabaseNames({
userDbName,
schoolDbName
});
return {
id: user.id,
@ -222,6 +230,7 @@ class AuthService {
storageService.set(StorageKeys.USER_ROLE, ccUser.user_type);
storageService.set(StorageKeys.USER, ccUser);
storageService.set(StorageKeys.SUPABASE_TOKEN, data.session.access_token);
storageService.set(StorageKeys.SUPABASE_SESSION, data.session);
logger.info('auth-service', '✅ Login successful', {
userId: ccUser.id,
@ -262,4 +271,3 @@ class AuthService {
}
export const authService = AuthService.getInstance();

View File

@ -1,5 +1,6 @@
import React from 'react';
import { TLUserPreferences, TLUser } from '@tldraw/tldraw';
import { Session } from '@supabase/supabase-js';
import { CCUser } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
@ -8,6 +9,7 @@ export enum StorageKeys {
USER = 'user',
USER_ROLE = 'user_role',
SUPABASE_TOKEN = 'supabase_token',
SUPABASE_SESSION = 'supabase_session',
MS_TOKEN = 'msAccessToken',
NEO4J_USER_DB = 'neo4jUserDbName',
NEO4J_WORKER_DB = 'neo4jWorkerDbName',
@ -27,6 +29,7 @@ interface StorageValueTypes {
[StorageKeys.USER]: CCUser;
[StorageKeys.USER_ROLE]: string;
[StorageKeys.SUPABASE_TOKEN]: string;
[StorageKeys.SUPABASE_SESSION]: Session;
[StorageKeys.MS_TOKEN]: string;
[StorageKeys.NEO4J_USER_DB]: string;
[StorageKeys.NEO4J_WORKER_DB]: string;

View File

@ -3,9 +3,10 @@ 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';
import { provisionUser } from '../provisioningService';
import { DatabaseNameService } from '../graph/databaseNameService';
const REGISTRATION_SERVICE = 'registration-service';
@ -76,38 +77,50 @@ export class RegistrationService {
storageService.set(StorageKeys.IS_NEW_REGISTRATION, true);
let provisioningToken = authData.session?.access_token || null;
if (!provisioningToken) {
const { data: sessionData } = await supabase.auth.getSession();
provisioningToken = sessionData.session?.access_token || null;
}
// 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', {
const provisioned = await provisionUser(ccUser.id, provisioningToken);
if (provisioned) {
ccUser.user_db_name = provisioned.user_db_name;
if (provisioned.worker_db_name) {
ccUser.school_db_name = provisioned.worker_db_name;
}
DatabaseNameService.rememberDatabaseNames({
userDbName: ccUser.user_db_name,
schoolDbName: ccUser.school_db_name
});
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
userId: ccUser.id,
userDbName: provisioned.user_db_name,
workerDbName: provisioned.worker_db_name
});
} else {
logger.warn(REGISTRATION_SERVICE, '⚠️ Provisioning skipped or pending', { userId: ccUser.id });
}
} catch (provisionError) {
logger.warn(REGISTRATION_SERVICE, '⚠️ Provisioning error', {
userId: ccUser.id,
hasUserNode: !!userNode
error: provisionError
});
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'
};
}
DatabaseNameService.rememberDatabaseNames({
userDbName: ccUser.user_db_name,
schoolDbName: ccUser.school_db_name
});
return {
user: ccUser,
accessToken: authData.session?.access_token || null,
userRole: credentials.role,
message: 'Registration successful'
};
} catch (error) {
logger.error(REGISTRATION_SERVICE, '❌ Registration failed:', error);
throw error;

View File

@ -1,14 +1,35 @@
import { logger } from '../../debugConfig';
import { storageService, StorageKeys } from '../auth/localStorageService';
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}`;
private static remember(key: StorageKeys, value?: string | null) {
if (!value) {
return;
}
storageService.set(key, value);
}
private static recall<T extends StorageKeys>(key: T): string | null {
return storageService.get(key);
}
private static sanitizeComponent(component: string, fallback = 'user'): string {
const cleaned = (component || fallback)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
return cleaned || fallback;
}
static getUserPrivateDB(userType: string, identifier: string): string {
const role = this.sanitizeComponent(userType || 'standard', 'standard');
const idComponent = this.sanitizeComponent(identifier, 'user');
const dbName = `${this.CC_USERS}.${role}.${idComponent}`;
logger.debug('database-name-service', '📥 Generating user private DB name', {
userType,
username,
identifier,
dbName
});
return dbName;
@ -24,18 +45,46 @@ export class DatabaseNameService {
}
static getDevelopmentSchoolDB(): string {
const dbName = `${this.CC_SCHOOLS}.development.default`;
logger.debug('database-name-service', '📥 Getting default school DB name', {
dbName
});
return dbName;
const stored = this.recall(StorageKeys.NEO4J_WORKER_DB);
if (stored && stored !== `${this.CC_SCHOOLS}.development.default`) {
logger.debug('database-name-service', '📥 Using stored school DB name', {
dbName: stored
});
return stored;
}
if (stored) {
logger.warn('database-name-service', '⚠️ Ignoring legacy stored school DB name', {
dbName: stored
});
} else {
logger.warn('database-name-service', '⚠️ No stored school DB name available; returning empty string');
}
return '';
}
static getContextDatabase(context: string, userType: string, username: string): string {
static rememberDatabaseNames({ userDbName, schoolDbName }: { userDbName?: string | null; schoolDbName?: string | null }) {
this.remember(StorageKeys.NEO4J_USER_DB, userDbName ?? null);
this.remember(StorageKeys.NEO4J_WORKER_DB, schoolDbName ?? null);
logger.debug('database-name-service', '💾 Remembered database names', {
userDbName,
schoolDbName
});
}
static getStoredUserDatabase(): string | null {
return this.recall(StorageKeys.NEO4J_USER_DB);
}
static getStoredSchoolDatabase(): string | null {
return this.recall(StorageKeys.NEO4J_WORKER_DB);
}
static getContextDatabase(context: string, userType: string, identifier: string): string {
logger.debug('database-name-service', '📥 Resolving context database', {
context,
userType,
username
identifier
});
// For school-related contexts, use the schools database
@ -48,7 +97,7 @@ export class DatabaseNameService {
}
// For user-specific contexts, use their private database
const userDb = this.getUserPrivateDB(userType, username);
const userDb = this.getUserPrivateDB(userType, identifier);
logger.debug('database-name-service', '✅ Using user private database for context', {
context,
dbName: userDb

View File

@ -8,20 +8,20 @@ import { logger } from '../../debugConfig';
export class GraphNeoDBService {
static async fetchConnectedNodesAndEdges(
unique_id: string,
uuid_string: string,
db_name: string,
editor: Editor
) {
try {
logger.debug('graph-service', '📤 Fetching connected nodes', {
unique_id,
uuid_string,
db_name
});
const response = await axios.get<ConnectedNodesResponse>(
'/database/tools/get-connected-nodes-and-edges', {
params: {
unique_id,
uuid_string,
db_name
}
}
@ -61,8 +61,8 @@ export class GraphNeoDBService {
if (isValidNodeType(connectedNode.type)) {
// Convert the simplified node structure to node_data format
const nodeData = {
unique_id: connectedNode.id,
tldraw_snapshot: connectedNode.tldraw_snapshot,
uuid_string: connectedNode.id,
node_storage_path: connectedNode.node_storage_path,
name: connectedNode.label,
__primarylabel__: connectedNode.type as keyof CCNodeTypes,
created: new Date().toISOString(),
@ -82,7 +82,7 @@ export class GraphNeoDBService {
for (const nodeData of nodesToProcess) {
await this.createOrUpdateNode(nodeData);
logger.debug('graph-service', '📝 Processed node', {
nodeId: nodeData.unique_id,
nodeId: nodeData.uuid_string,
nodeType: nodeData.__primarylabel__
});
}
@ -105,7 +105,7 @@ export class GraphNeoDBService {
private static async createOrUpdateNode(
nodeData: NodeResponse['node_data']
) {
const uniqueId = nodeData.unique_id;
const uniqueId = nodeData.uuid_string;
const nodeType = nodeData.__primarylabel__;
if (!isValidNodeType(nodeType)) {
@ -126,6 +126,27 @@ export class GraphNeoDBService {
const defaultProps = shapeUtil.prototype.getDefaultProps();
// Create the shape with proper typing based on the node type
// Filter out properties that TLDraw doesn't expect
const { path, cc_username, user_db_name, ...filteredNodeData } = nodeData;
// Map backend properties to TLDraw shape properties
const mappedProps = {
...defaultProps,
...filteredNodeData,
__primarylabel__: nodeData.__primarylabel__,
uuid_string: nodeData.uuid_string,
node_storage_path: nodeData.node_storage_path as string || '',
};
// Add missing properties for cc-user-node
if (shapeType === 'cc-user-node') {
mappedProps.user_id = nodeData.uuid_string; // Use uuid_string as user_id
mappedProps.worker_node_data = JSON.stringify({
cc_username: nodeData.cc_username || '',
user_db_name: nodeData.user_db_name || ''
});
}
const shape = {
id: createShapeId(uniqueId),
type: shapeType,
@ -137,13 +158,7 @@ export class GraphNeoDBService {
isLocked: false,
opacity: 1,
meta: {},
props: {
...defaultProps,
...nodeData,
__primarylabel__: nodeData.__primarylabel__,
unique_id: nodeData.unique_id,
tldraw_snapshot: nodeData.path as string || '',
}
props: mappedProps
};
// Add to graphState

View File

@ -2,7 +2,7 @@ import { CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types'
import { logger } from '../../debugConfig';
export interface BaseNodeData {
unique_id: string;
uuid_string: string;
path: string;
__primarylabel__: string;
[key: string]: unknown;

View File

@ -46,10 +46,10 @@ class NeoRegistrationService {
// 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);
formData.append('school_uuid_string', schoolNode.uuid_string);
formData.append('school_name', schoolNode.name);
formData.append('school_website', schoolNode.website);
formData.append('school_node_storage_path', schoolNode.node_storage_path);
// Add worker data based on role
const workerData = role.includes('teacher') ? {
@ -72,8 +72,8 @@ class NeoRegistrationService {
userName: username,
userEmail: user.email,
schoolNode: schoolNode ? {
uuid: schoolNode.school_uuid,
name: schoolNode.school_name
uuid_string: schoolNode.uuid_string,
name: schoolNode.name
} : null
});
@ -105,7 +105,7 @@ class NeoRegistrationService {
logger.info('neo4j-service', '✅ Neo4j user registration successful', {
userId: user.id,
nodeId: userNode.unique_id,
nodeId: userNode.uuid_string,
hasCalendar: !!response.data.data.calendar_nodes
});
@ -133,11 +133,11 @@ class NeoRegistrationService {
}
}
async fetchSchoolNode(schoolUuid: string): Promise<CCSchoolNodeProps> {
logger.debug('neo4j-service', '🔄 Fetching school node', { schoolUuid });
async fetchSchoolNode(schoolUrn: string): Promise<CCSchoolNodeProps> {
logger.debug('neo4j-service', '🔄 Fetching school node', { schoolUrn });
try {
const response = await axiosInstance.get(`/database/tools/get-school-node?school_uuid=${schoolUuid}`);
const response = await axiosInstance.get(`/database/tools/get-school-node?school_urn=${schoolUrn}`);
if (response.data?.status === 'success' && response.data.school_node) {
logger.info('neo4j-service', '✅ School node fetched successfully');

View File

@ -33,9 +33,10 @@ export class NeoShapeService {
const width = 500;
const height = 350;
// Process the node data
// Process the node data - filter out properties that TLDraw doesn't expect
const { cc_username, user_db_name, path, ...filteredNodeData } = nodeData;
const processedProps = {
...this.processDateTimeFields(nodeData),
...this.processDateTimeFields(filteredNodeData),
title: nodeData.title || node.label,
w: width,
h: height,
@ -49,10 +50,19 @@ export class NeoShapeService {
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
uuid_string: node.id,
node_storage_path: node.node_storage_path
};
// Add missing properties for cc-user-node
if (shapeType === 'cc-user-node') {
processedProps.user_id = node.id; // Use node.id as user_id
processedProps.worker_node_data = JSON.stringify({
cc_username: nodeData.cc_username || '',
user_db_name: nodeData.user_db_name || ''
});
}
logger.debug('neo-shape-service', '📄 Created shape configuration', {
nodeId: node.id,
shapeType,

View File

@ -22,7 +22,7 @@ export interface TeacherTimetableEvent {
subjectClass: string;
color: string;
periodCode: string;
tldraw_snapshot?: string;
node_storage_path?: string;
};
}
@ -42,21 +42,21 @@ export class TimetableNeoDBService {
const formData = new FormData();
formData.append('file', file);
formData.append('user_node', JSON.stringify({
unique_id: userNode.unique_id,
uuid_string: userNode.uuid_string,
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,
node_storage_path: userNode.node_storage_path,
worker_node_data: userNode.worker_node_data
}));
formData.append('worker_node', JSON.stringify({
unique_id: workerNode.unique_id,
uuid_string: workerNode.uuid_string,
teacher_code: workerNode.teacher_code,
teacher_name_formal: workerNode.teacher_name_formal,
teacher_email: workerNode.teacher_email,
tldraw_snapshot: workerNode.tldraw_snapshot,
node_storage_path: workerNode.node_storage_path,
worker_db_name: workerNode.school_db_name,
user_db_name: workerNode.user_db_name
}));
@ -92,18 +92,18 @@ export class TimetableNeoDBService {
}
static async fetchTeacherTimetableEvents(
unique_id: string,
uuid_string: string,
school_db_name: string
): Promise<TeacherTimetableEvent[]> {
try {
logger.debug('timetable-service', '📤 Fetching timetable events', {
unique_id,
uuid_string,
school_db_name
});
const response = await axios.get('/calendar/get_teacher_timetable_events', {
params: {
unique_id,
uuid_string,
school_db_name
}
});
@ -216,8 +216,8 @@ export class TimetableNeoDBService {
}
// 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 requiredWorkerFields = ['uuid_string', 'teacher_code', 'teacher_name_formal', 'teacher_email', 'worker_db_name', 'path'];
const requiredUserFields = ['uuid_string', '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));

View File

@ -8,7 +8,11 @@ import { useNavigationStore } from '../../stores/navigationStore';
import { DatabaseNameService } from './databaseNameService';
// Dev configuration - only hardcoded value we need
const DEV_SCHOOL_UUID = 'kevlarai';
const DEV_SCHOOL_NAME = 'default';
const DEV_SCHOOL_GROUP = 'development'
const ADMIN_USER_NAME = 'kcar';
const ADMIN_USER_GROUP = 'admin';
interface ShapeState {
parentId: TLShapeId | null;
@ -29,8 +33,8 @@ interface NodeResponse {
interface NodeDataResponse {
__primarylabel__: string;
unique_id: string;
tldraw_snapshot: string;
uuid_string: string;
node_storage_path: string;
created: string;
merged: string;
state: ShapeState | null;
@ -47,7 +51,7 @@ interface DefaultNodeResponse {
status: string;
node: {
id: string;
tldraw_snapshot: string;
node_storage_path: string;
type: string;
label: string;
data: NodeDataResponse;
@ -195,7 +199,7 @@ export class UserNeoDBService {
} as CCCalendarNodeProps;
logger.debug('neo4j-service', '✅ Found calendar node', {
nodeId: calendarNode.id,
tldraw_snapshot: calendarNode.data.tldraw_snapshot
node_storage_path: calendarNode.data.node_storage_path
});
} else {
logger.debug('neo4j-service', ' No calendar node found');
@ -224,7 +228,7 @@ export class UserNeoDBService {
} as CCTeacherNodeProps;
logger.debug('neo4j-service', '✅ Found teacher node', {
nodeId: teacherNode.id,
tldraw_snapshot: teacherNode.data.tldraw_snapshot,
node_storage_path: teacherNode.data.node_storage_path,
userDbName,
workerDbName
});
@ -242,9 +246,9 @@ export class UserNeoDBService {
hasCalendar: !!processedNodes.connectedNodes.calendar,
hasTeacher: !!processedNodes.connectedNodes.teacher,
teacherData: processedNodes.connectedNodes.teacher ? {
unique_id: processedNodes.connectedNodes.teacher.unique_id,
uuid_string: processedNodes.connectedNodes.teacher.uuid_string,
school_db_name: processedNodes.connectedNodes.teacher.school_db_name,
tldraw_snapshot: processedNodes.connectedNodes.teacher.tldraw_snapshot
node_storage_path: processedNodes.connectedNodes.teacher.node_storage_path
} : null
});
@ -259,8 +263,8 @@ export class UserNeoDBService {
}
}
static getUserDatabaseName(userType: string, username: string): string {
return DatabaseNameService.getUserPrivateDB(userType, username);
static getUserDatabaseName(userType: string, identifier: string): string {
return DatabaseNameService.getUserPrivateDB(userType, identifier);
}
static getSchoolDatabaseName(schoolId: string): string {
@ -268,10 +272,10 @@ export class UserNeoDBService {
}
static getDefaultSchoolDatabaseName(): string {
return DatabaseNameService.getDevelopmentSchoolDB();
return DatabaseNameService.getStoredSchoolDatabase() || '';
}
static async fetchNodeData(nodeId: string, dbName: string): Promise<{ node_type: string; node_data: NodeResponse['nodes']['userNode'] } | null> {
static async fetchNodeData(nodeId: string, dbName: string): Promise<{ node_type: string; node_data: NodeDataResponse } | null> {
try {
logger.debug('neo4j-service', '🔄 Fetching node data', { nodeId, dbName });
@ -279,11 +283,11 @@ export class UserNeoDBService {
status: string;
node: {
node_type: string;
node_data: NodeResponse['nodes']['userNode'];
node_data: NodeDataResponse;
};
}>('/database/tools/get-node', {
params: {
unique_id: nodeId,
uuid_string: nodeId,
db_name: dbName
}
});
@ -300,18 +304,78 @@ export class UserNeoDBService {
}
static getNodeDatabaseName(node: NavigationNode): string {
// Validate that node and node_storage_path exist
if (!node || !node.node_storage_path) {
logger.error('neo4j-service', '❌ Invalid node or missing node_storage_path', {
node: node ? { id: node.id, type: node.type, label: node.label } : null,
hasStoragePath: !!node?.node_storage_path
});
throw new Error('Node is missing required storage path information');
}
// 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('/');
if (node.node_storage_path.startsWith('users/')) {
const parts = node.node_storage_path.split('/');
const databaseIndex = parts.indexOf('databases');
if (databaseIndex >= 0 && parts.length > databaseIndex + 1) {
return parts[databaseIndex + 1];
}
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
if (parts.length >= 4) {
return parts[3];
}
logger.warn('neo4j-service', '⚠️ Unexpected user path format', { path: node.node_storage_path });
return 'cc.users';
}
// For Supabase Storage paths (cc.public.snapshots/...), determine database based on node type
if (node.node_storage_path.startsWith('cc.public.snapshots/')) {
const parts = node.node_storage_path.split('/');
const nodeType = parts[1]; // e.g., 'User', 'Teacher', 'School'
if (nodeType === 'User') {
return DatabaseNameService.getStoredUserDatabase() || 'cc.users';
} else if (nodeType === 'Teacher' || nodeType === 'Student') {
return DatabaseNameService.getStoredSchoolDatabase() || 'cc.institutes';
} else if (nodeType === 'School') {
return DatabaseNameService.getStoredSchoolDatabase() || 'cc.institutes';
}
}
// For school/worker nodes, extract from the path or use a default
if (node.node_storage_path.startsWith('schools/')) {
const parts = node.node_storage_path.split('/');
const databaseIndex = parts.indexOf('databases');
if (databaseIndex >= 0 && parts.length > databaseIndex + 1) {
return parts[databaseIndex + 1];
}
if (parts.length >= 4) {
return parts[3];
}
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
if (storedSchoolDb) {
logger.warn('neo4j-service', '⚠️ Falling back to stored school database name', {
path: node.node_storage_path,
storedSchoolDb
});
return storedSchoolDb;
}
logger.warn('neo4j-service', '⚠️ Could not determine school database from path', { path: node.node_storage_path });
return DatabaseNameService.getStoredSchoolDatabase() || 'cc.institutes';
}
// Try to extract from path, but provide fallback
const parts = node.node_storage_path.split('/');
if (parts.length >= 4) {
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];
// Default fallback
logger.warn('neo4j-service', '⚠️ Using fallback database name', {
path: node.node_storage_path,
nodeType: node.type
});
return 'cc.users'; //TODO: remove hard-coding
}
static async getDefaultNode(context: NodeContext, dbName: string): Promise<NavigationNode | null> {
@ -334,7 +398,7 @@ export class UserNeoDBService {
if (response.data?.status === 'success' && response.data.node) {
return {
id: response.data.node.id,
tldraw_snapshot: response.data.node.tldraw_snapshot,
node_storage_path: response.data.node.node_storage_path,
type: response.data.node.type,
label: response.data.node.label,
data: response.data.node.data

View File

@ -10,8 +10,7 @@ export const initializeApp = () => {
logger.debug('app', '🚀 App initializing', {
isDevMode: import.meta.env.VITE_DEV === 'true',
environment: import.meta.env.MODE,
appName: import.meta.env.VITE_APP_NAME
environment: import.meta.env.MODE
});
// Set the app element for react-modal

View File

@ -0,0 +1,89 @@
import axiosInstance from '../axiosConfig';
import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient';
export interface ProvisionUserResponse {
user_db_name: string;
worker_db_name?: string | null;
worker_type?: string | null;
}
export interface ProvisionSchoolResponse {
db_name: string;
curriculum_db_name: string;
}
export async function provisionUser(userId: string, accessToken?: string | null): Promise<ProvisionUserResponse | null> {
try {
let token = accessToken || null;
if (!token) {
const { data: sessionData } = await supabase.auth.getSession();
token = sessionData.session?.access_token || null;
}
if (!token) {
logger.warn('provisioning-service', '⚠️ No access token available for provisioning', { userId });
return null;
}
logger.debug('provisioning-service', '🔄 Provisioning user', {
userId,
hasToken: !!token,
baseURL: axiosInstance.defaults.baseURL
});
const { data } = await axiosInstance.post<ProvisionUserResponse>(
'/provisioning/users',
{ user_id: userId },
{
headers: { Authorization: `Bearer ${token}` },
timeout: 5000 // 5 second timeout for provisioning requests
}
);
logger.info('provisioning-service', '✅ User provisioned', {
userId,
userDbName: data.user_db_name,
workerDbName: data.worker_db_name
});
return data;
} catch (error) {
logger.warn('provisioning-service', '⚠️ Failed to provision user', {
userId,
error,
});
return null;
}
}
export async function provisionSchool(instituteId: string, accessToken?: string | null): Promise<ProvisionSchoolResponse | null> {
try {
let token = accessToken || null;
if (!token) {
const { data: sessionData } = await supabase.auth.getSession();
token = sessionData.session?.access_token || null;
}
if (!token) {
logger.warn('provisioning-service', '⚠️ No access token available for school provisioning', { instituteId });
return null;
}
const { data } = await axiosInstance.post<ProvisionSchoolResponse>(
'/provisioning/schools',
{ institute_id: instituteId },
{
headers: { Authorization: `Bearer ${token}` },
timeout: 5000 // 5 second timeout for provisioning requests
}
);
logger.info('provisioning-service', '✅ School provisioned', {
instituteId,
dbName: data.db_name,
});
return data;
} catch (error) {
logger.warn('provisioning-service', '⚠️ Failed to provision school', {
instituteId,
error,
});
return null;
}
}

View File

@ -1,5 +1,5 @@
// External imports
import { loadSnapshot, TLStore, getSnapshot } from '@tldraw/tldraw';
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService';
@ -13,13 +13,14 @@ export interface LoadingState {
const EMPTY_NODE: NavigationNode = {
id: '',
tldraw_snapshot: '',
node_storage_path: '',
type: '',
label: ''
};
export class NavigationSnapshotService {
private store: TLStore;
private editor: Editor | null = null;
private currentNodePath: string | null = null;
private isAutoSaveEnabled = true;
private isSaving = false;
@ -27,10 +28,19 @@ export class NavigationSnapshotService {
private pendingOperation: { save?: string; load?: string } | null = null;
private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(store: TLStore) {
constructor(store: TLStore, editor?: Editor) {
this.store = store;
this.editor = editor || null;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id
storeId: store.id,
hasEditor: !!editor
});
}
setEditor(editor: Editor): void {
this.editor = editor;
logger.debug('snapshot-service', '🔄 Editor reference updated', {
editorId: editor.store.id
});
}
@ -43,7 +53,8 @@ export class NavigationSnapshotService {
dbName: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService
sharedStore?: SharedStoreService,
editor?: Editor
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
@ -54,7 +65,7 @@ export class NavigationSnapshotService {
});
const response = await axios.get(
'/database/tldraw_fs/get_tldraw_node_file', {
'/database/tldraw_supabase/get_tldraw_node_file', {
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
@ -63,13 +74,127 @@ export class NavigationSnapshotService {
);
const snapshot = response.data;
logger.debug('snapshot-service', '🔍 Snapshot data received', {
hasSnapshot: !!snapshot,
hasDocument: !!snapshot?.document,
hasSession: !!snapshot?.session,
hasSchemaVersion: !!snapshot?.schemaVersion,
schemaVersion: snapshot?.schemaVersion,
snapshotKeys: snapshot ? Object.keys(snapshot) : []
});
if (snapshot && snapshot.document && snapshot.session) {
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState);
} else {
loadSnapshot(store, snapshot);
logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', {
hasStore: !!store,
snapshotType: typeof snapshot,
snapshotKeys: Object.keys(snapshot),
snapshotSchemaVersion: snapshot?.schemaVersion,
snapshotDocument: !!snapshot?.document,
snapshotSession: !!snapshot?.session
});
// Create a defensive copy to ensure the snapshot doesn't get modified
const snapshotCopy = {
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion,
document: snapshot.document,
session: snapshot.session
};
logger.debug('snapshot-service', '🔄 Calling loadSnapshot with defensive copy', {
copySchemaVersion: snapshotCopy.schemaVersion,
copyDocument: !!snapshotCopy.document,
copySession: !!snapshotCopy.session,
storeType: typeof store,
storeIsNull: store === null,
storeIsUndefined: store === undefined,
storeKeys: store ? Object.keys(store) : 'N/A'
});
// Debug: Log the snapshot schema sequences
if (snapshotCopy.document?.schema?.sequences) {
logger.debug('snapshot-service', '🔍 Snapshot schema sequences:', snapshotCopy.document.schema.sequences);
const customSequences = Object.keys(snapshotCopy.document.schema.sequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences in snapshot:', customSequences);
}
// Debug: Log the store schema sequences
if (store?.schema) {
const storeSequences = store.schema.serialize().sequences;
logger.debug('snapshot-service', '🔍 Store schema sequences:', storeSequences);
const storeCustomSequences = Object.keys(storeSequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences in store:', storeCustomSequences);
}
// Add try-catch around the loadSnapshot call to get more specific error info
try {
// Ensure store is properly initialized before loading snapshot
if (!store) {
throw new Error('Store is null or undefined');
}
// Validate snapshot structure before loading
if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) {
throw new Error('Invalid snapshot structure');
}
// Check for schema migrations and handle them properly
logger.debug('snapshot-service', '🔄 Checking for schema migrations', {
storeId: store.id,
storeType: typeof store,
storeConstructor: store.constructor.name,
snapshotSchemaVersion: snapshotCopy.schemaVersion,
snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}),
snapshotSessionKeys: Object.keys(snapshotCopy.session || {})
});
try {
// Try to load the snapshot directly first
logger.debug('snapshot-service', '🔄 Attempting to load snapshot directly');
if (editor) {
loadSnapshot(editor.store, snapshotCopy);
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
} else {
// Fallback: use global loadSnapshot if no editor available
logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot');
loadSnapshot(store, snapshotCopy);
logger.debug('snapshot-service', '✅ Snapshot loaded successfully via global loadSnapshot');
}
} catch (migrationError) {
// Check if this is a schema migration error that we can safely ignore
const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError);
const isSchemaMigrationError = errorMessage.includes('migration') ||
errorMessage.includes('schema') ||
errorMessage.includes('Incompatible');
if (isSchemaMigrationError) {
logger.debug('snapshot-service', ' Schema migration warning (non-critical)', {
error: errorMessage
});
// Continue with empty store - this is expected for some snapshots
} else {
logger.warn('snapshot-service', '⚠️ Unexpected load error', {
error: errorMessage
});
}
}
logger.debug('snapshot-service', '✅ loadSnapshot call succeeded');
setLoadingState({ status: 'ready', error: '' });
} catch (loadError) {
logger.error('snapshot-service', '❌ loadSnapshot call failed', {
error: loadError instanceof Error ? loadError.message : String(loadError),
storeType: typeof store,
storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function',
snapshotType: typeof snapshotCopy,
snapshotKeys: Object.keys(snapshotCopy)
});
throw loadError;
}
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
}
} else {
@ -100,8 +225,24 @@ export class NavigationSnapshotService {
const snapshot = getSnapshot(store);
// Debug: Log what we're saving
logger.debug('snapshot-service', '🔍 Snapshot being saved:', {
hasSnapshot: !!snapshot,
snapshotKeys: Object.keys(snapshot || {}),
schemaVersion: snapshot?.schemaVersion,
hasDocument: !!snapshot?.document,
hasSession: !!snapshot?.session
});
// Debug: Log the schema sequences in the snapshot being saved
if (snapshot?.document?.schema?.sequences) {
logger.debug('snapshot-service', '🔍 Schema sequences being saved:', snapshot.document.schema.sequences);
const customSequences = Object.keys(snapshot.document.schema.sequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences being saved:', customSequences);
}
const response = await axios.post(
'/database/tldraw_fs/set_tldraw_node_file',
'/database/tldraw_supabase/set_tldraw_node_file',
snapshot,
{
params: {
@ -177,34 +318,37 @@ export class NavigationSnapshotService {
const dbName = user.user_db_name;
logger.debug('snapshot-service', '📥 Loading snapshot', {
nodePath: node.tldraw_snapshot,
nodePath: node.node_storage_path,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.tldraw_snapshot,
node.node_storage_path,
dbName,
this.store,
(state: LoadingState) => {
if (state.status === 'ready') {
this.currentNodePath = node.tldraw_snapshot;
this.currentNodePath = node.node_storage_path;
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
nodePath: node.tldraw_snapshot
nodePath: node.node_storage_path,
currentNodePath: this.currentNodePath
});
} else if (state.status === 'error') {
logger.error('snapshot-service', '❌ Error in load callback', {
error: state.error,
nodePath: node.tldraw_snapshot
nodePath: node.node_storage_path
});
}
}
},
undefined, // sharedStore
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot
);
} catch (error) {
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath: node.tldraw_snapshot
nodePath: node.node_storage_path
});
throw error;
} finally {
@ -240,16 +384,16 @@ export class NavigationSnapshotService {
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,
from: fromNode.node_storage_path,
to: toNode.node_storage_path,
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
save: fromNode.node_storage_path || undefined,
load: toNode.node_storage_path
};
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
return;
@ -261,10 +405,10 @@ export class NavigationSnapshotService {
logger.debug('snapshot-service', '🧹 Cleared current node path');
// Load the new node's snapshot
if (toNode.tldraw_snapshot) {
if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode);
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
nodePath: toNode.tldraw_snapshot
nodePath: toNode.node_storage_path
});
}
@ -274,16 +418,16 @@ export class NavigationSnapshotService {
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
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null,
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null
);
logger.debug('snapshot-service', '✅ Completed pending operation');
}
} catch (error) {
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
error: error instanceof Error ? error.message : 'Unknown error',
fromPath: fromNode.tldraw_snapshot,
toPath: toNode.tldraw_snapshot
fromPath: fromNode.node_storage_path,
toPath: toNode.node_storage_path
});
throw error;
}
@ -303,6 +447,8 @@ export class NavigationSnapshotService {
async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath);
} else {
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
}
}

View File

@ -207,7 +207,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
logger.debug('context-switch', '✨ Default node fetched', {
nodeId: defaultNode.id,
tldraw_snapshot: defaultNode.tldraw_snapshot,
node_storage_path: defaultNode.node_storage_path,
type: defaultNode.type
});
@ -353,7 +353,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
const node: NavigationNode = {
id: nodeId,
tldraw_snapshot: nodeData.node_data.tldraw_snapshot || '',
node_storage_path: nodeData.node_data.node_storage_path || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
type: nodeData.node_type
};
@ -361,7 +361,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
logger.debug('navigation', '📍 Adding new node to history', {
nodeId: node.id,
type: node.type,
tldraw_snapshot: node.tldraw_snapshot
node_storage_path: node.node_storage_path
});
// Add to history and update state
@ -414,7 +414,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
if (nodeData) {
const node: NavigationNode = {
id: currentState.node.id,
tldraw_snapshot: nodeData.node_data.tldraw_snapshot || '',
node_storage_path: nodeData.node_data.node_storage_path || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
type: nodeData.node_type
};

View File

@ -1,8 +1,7 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { logger } from './debugConfig';
const appProtocol = import.meta.env.VITE_APP_PROTOCOL;
const supabaseUrl = `${appProtocol}://${import.meta.env.VITE_SUPABASE_URL}`;
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
logger.info('supabase-client', '🔄 Supabase configuration', {
@ -42,6 +41,10 @@ const getSupabaseClient = () => {
'X-Client-Info': 'classroom-copilot',
},
},
// Allow JWT issuer mismatch for local development
db: {
schema: 'public'
}
}
);

View File

@ -3,10 +3,10 @@ import { CCNodeTypes } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
export interface NodeResponse {
node_data: {
__primarylabel__: keyof CCNodeTypes;
unique_id: string;
uuid_string: string;
title?: string;
name?: string;
tldraw_snapshot: string;
node_storage_path: string;
created: string;
merged: string;
[key: string]: unknown;
@ -16,7 +16,7 @@ export interface NodeResponse {
export interface ConnectedNodeResponse {
id: string;
tldraw_snapshot: string;
node_storage_path: string;
label: string;
type: string;
}

View File

@ -16,7 +16,7 @@ export type NodeData = {
backgroundColor: string;
isLocked: boolean;
__primarylabel__: string;
unique_id: string;
tldraw_snapshot: string;
uuid_string: string;
node_storage_path: string;
[key: string]: string | number | boolean | null | ShapeState | Record<string, unknown> | undefined;
}

View File

@ -198,13 +198,13 @@ export interface ContextDefinition {
// Navigation Node Types
export interface NavigationNode {
id: string;
tldraw_snapshot: string;
node_storage_path: string;
label: string;
type: string;
context?: NavigationContextState;
data?: {
unique_id: string;
tldraw_snapshot: string;
uuid_string: string;
node_storage_path: string;
title?: string;
name?: string;
[key: string]: unknown;

View File

@ -50,7 +50,7 @@ const TeacherNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) =>
'Teacher ID': data.teacher_code,
'Teacher Email': data.teacher_email,
}}
id={data.unique_id as string}
id={data.uuid_string as string}
type='userNode'
dragging={false}
zIndex={1}
@ -70,7 +70,7 @@ const TeacherTimetableNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirecti
data={{
label: data.label,
}}
id={data.unique_id as string}
id={data.uuid_string as string}
type='userNode'
dragging={false}
zIndex={1}
@ -93,7 +93,7 @@ const SubjectClassNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }
'Subject': data.subject,
'Subject Code': data.subject_code,
}}
id={data.unique_id as string}
id={data.uuid_string as string}
type='userNode'
dragging={false}
zIndex={1}

216
src/utils/folderPicker.ts Normal file
View File

@ -0,0 +1,216 @@
/**
* Folder Picker Utility
* =====================
*
* Handles directory selection with hybrid approach:
* - File System Access API for Chromium browsers (best UX)
* - webkitdirectory fallback for all other browsers
*
* Based on the ChatGPT recommendation for "it just works" UX.
*/
export interface FileWithPath extends File {
relativePath: string;
}
/**
* Pick a directory using the best available method
* @returns Promise<FileWithPath[]> - Array of files with relative paths
* @throws 'fallback-input' - Indicates fallback input should be used
*/
export async function pickDirectory(): Promise<FileWithPath[]> {
// Check if we have the modern File System Access API (Chromium)
if ('showDirectoryPicker' in window) {
try {
const handle = await (window as any).showDirectoryPicker({
mode: 'read'
});
const files: FileWithPath[] = [];
await walkDirectoryHandle(handle, '', files);
return files;
} catch (error: any) {
if (error.name === 'AbortError') {
throw new Error('user-cancelled');
}
// Fall back to input method
throw new Error('fallback-input');
}
}
// No native directory picker support, use fallback
throw new Error('fallback-input');
}
/**
* Recursively walk directory handle and collect files
* @param dirHandle - Directory handle from File System Access API
* @param prefix - Current path prefix
* @param files - Array to collect files
*/
async function walkDirectoryHandle(dirHandle: any, prefix: string, files: FileWithPath[]) {
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
try {
const file = await entry.getFile();
const relativePath = `${prefix}${entry.name}`;
// Add relative path property
const fileWithPath = file as FileWithPath;
fileWithPath.relativePath = relativePath;
files.push(fileWithPath);
} catch (error) {
console.warn(`Failed to read file ${entry.name}:`, error);
}
} else if (entry.kind === 'directory') {
// Recursively process subdirectory
await walkDirectoryHandle(entry, `${prefix}${entry.name}/`, files);
}
}
}
/**
* Process files from webkitdirectory input
* @param fileList - FileList from input element
* @returns FileWithPath[] - Array of files with relative paths
*/
export function processDirectoryFiles(fileList: FileList): FileWithPath[] {
const files: FileWithPath[] = [];
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
// webkitRelativePath preserves the directory structure
const relativePath = (file as any).webkitRelativePath || file.name;
// Skip empty files (usually directories)
if (file.size === 0 && relativePath.endsWith('/')) {
continue;
}
const fileWithPath = file as FileWithPath;
fileWithPath.relativePath = relativePath;
files.push(fileWithPath);
}
return files;
}
/**
* Calculate total size and file count for a file array
* @param files - Array of files
* @returns Object with total size and count
*/
export function calculateDirectoryStats(files: FileWithPath[]) {
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const fileCount = files.length;
// Get unique directories
const directories = new Set<string>();
files.forEach(file => {
const pathParts = file.relativePath.split('/');
for (let i = 1; i < pathParts.length; i++) {
directories.add(pathParts.slice(0, i).join('/'));
}
});
return {
fileCount,
totalSize,
directoryCount: directories.size,
formattedSize: formatFileSize(totalSize)
};
}
/**
* Format file size in human-readable format
* @param bytes - Size in bytes
* @returns Formatted string
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Create a directory tree structure from files
* @param files - Array of files with relative paths
* @returns Tree structure object
*/
export function createDirectoryTree(files: FileWithPath[]) {
const tree: any = {};
files.forEach(file => {
const pathParts = file.relativePath.split('/');
let currentLevel = tree;
// Navigate through directory structure
for (let i = 0; i < pathParts.length - 1; i++) {
const dirName = pathParts[i];
if (!currentLevel[dirName]) {
currentLevel[dirName] = {
type: 'directory',
children: {},
fileCount: 0,
totalSize: 0
};
}
currentLevel = currentLevel[dirName].children;
}
// Add file to the tree
const fileName = pathParts[pathParts.length - 1];
currentLevel[fileName] = {
type: 'file',
file: file,
size: file.size,
mimeType: file.type
};
// Update parent directory stats
let statsLevel = tree;
for (let i = 0; i < pathParts.length - 1; i++) {
const dirName = pathParts[i];
if (statsLevel[dirName]) {
statsLevel[dirName].fileCount++;
statsLevel[dirName].totalSize += file.size;
statsLevel = statsLevel[dirName].children;
}
}
});
return tree;
}
/**
* Check if directory picker is supported
* @returns boolean
*/
export function isDirectoryPickerSupported(): boolean {
return 'showDirectoryPicker' in window;
}
/**
* Get browser compatibility info
* @returns Object with compatibility details
*/
export function getBrowserCompatibility() {
const isChromium = 'showDirectoryPicker' in window;
const supportsWebkitDirectory = HTMLInputElement.prototype.hasOwnProperty('webkitdirectory');
return {
hasNativeDirectoryPicker: isChromium,
hasWebkitDirectory: supportsWebkitDirectory,
recommendedMethod: isChromium ? 'native' : 'fallback',
browserType: isChromium ? 'chromium' : 'other'
};
}

View File

@ -57,7 +57,7 @@ export const CalendarComponent: React.FC<CalendarComponentProps> = ({ shape }) =
try {
const fetchedEvents = await TimetableNeoDBService.fetchTeacherTimetableEvents(
workerNode.nodeData.unique_id,
workerNode.nodeData.uuid_string,
workerDbName || ''
);

View File

@ -134,13 +134,13 @@ export const EventDetailsDialog = ({
setFileLoadingState
}: EventDetailsDialogProps) => {
const handleOpenFile = () => {
if (!selectedEvent?.extendedProps?.tldraw_snapshot || !workerDbName) {
if (!selectedEvent?.extendedProps?.node_storage_path || !workerDbName) {
console.error('❌ Failed to open tldraw file - missing snapshot or db name')
return
}
onOpenFile(
selectedEvent.extendedProps.tldraw_snapshot,
selectedEvent.extendedProps.node_storage_path,
workerDbName,
editor,
setFileLoadingState
@ -166,7 +166,7 @@ export const EventDetailsDialog = ({
<p style={{color: 'red'}}>Error: {fileLoadingState.error}</p>
)}
{selectedEvent.extendedProps?.tldraw_snapshot && fileLoadingState.status !== 'loading' && (
{selectedEvent.extendedProps?.node_storage_path && fileLoadingState.status !== 'loading' && (
<TldrawUiButton type="normal" onClick={handleOpenFile}>
<TldrawUiButtonLabel>
Open Tldraw File <FaExternalLinkAlt style={{ marginLeft: '8px' }} />

View File

@ -32,19 +32,19 @@ export class CCSchoolNodeShapeUtil extends CCBaseShapeUtil<CCSchoolNodeShape> {
<div style={styles.container}>
<NodeProperty
label="School Name"
value={shape.props.school_name}
value={shape.props.name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="School Website"
value={shape.props.school_website}
value={shape.props.website}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="School UUID"
value={shape.props.school_uuid}
value={shape.props.uuid_string}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>

View File

@ -51,12 +51,12 @@ export class CCTeacherNodeShapeUtil extends CCBaseShapeUtil<CCTeacherNodeShape>
{ label: 'Teacher Name', value: props.teacher_name_formal },
{ label: 'Teacher Code', value: props.teacher_code },
{ label: 'Email', value: props.teacher_email },
{ label: 'Node Snapshot', value: props.tldraw_snapshot }
{ label: 'Node Snapshot', value: props.node_storage_path }
]
return (
<div style={styles.container}>
{defaultComponent && <DefaultNodeComponent tldraw_snapshot={props.tldraw_snapshot} />}
{defaultComponent && <DefaultNodeComponent node_storage_path={props.node_storage_path} />}
{properties.map((prop, index) => (
<div key={index} style={styles.property.wrapper}>
<span style={styles.property.label}>{prop.label}:</span>

View File

@ -51,7 +51,7 @@ export class CCUserNodeShapeUtil extends CCBaseShapeUtil<CCUserNodeShape> {
{ label: 'User Email', value: props.user_email },
{ label: 'User Type', value: props.user_type },
{ label: 'User ID', value: props.user_id },
{ label: 'Node Snapshot', value: props.tldraw_snapshot },
{ label: 'Node Snapshot', value: props.node_storage_path },
{ label: 'Worker Node Data', value: props.worker_node_data }
] : [
{ label: 'User Name', value: props.user_name },
@ -60,7 +60,7 @@ export class CCUserNodeShapeUtil extends CCBaseShapeUtil<CCUserNodeShape> {
return (
<div style={styles.container}>
{defaultComponent && <DefaultNodeComponent tldraw_snapshot={props.tldraw_snapshot} />}
{defaultComponent && <DefaultNodeComponent node_storage_path={props.node_storage_path} />}
{properties.map((prop, index) => (
<div key={index} style={styles.property.wrapper}>
<span style={styles.property.label}>{prop.label}:</span>

View File

@ -1,23 +1,23 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty, formatDate } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCUserTimetableLessonNodeProps } from './cc-graph-props'
import { ccGraphShapeProps, getDefaultCCTimetableLessonNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCUserTimetableLessonNodeProps } from './cc-graph-types'
import { CCTimetableLessonNodeProps } from './cc-graph-types'
export interface CCUserTimetableLessonNodeShape extends CCBaseShape {
export interface CCTimetableLessonNodeShape extends CCBaseShape {
type: 'cc-user-timetable-lesson-node'
props: CCUserTimetableLessonNodeProps
props: CCTimetableLessonNodeProps
}
export class CCUserTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCUserTimetableLessonNodeShape> {
export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableLessonNodeShape> {
static type = 'cc-user-timetable-lesson-node' as const
static props = ccGraphShapeProps['cc-user-timetable-lesson-node']
getDefaultProps(): CCUserTimetableLessonNodeShape['props'] {
const defaultProps = getDefaultCCUserTimetableLessonNodeProps() as CCUserTimetableLessonNodeShape['props']
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCUserTimetableLessonNodeShapeUtil.type]]
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
const defaultProps = getDefaultCCTimetableLessonNodeProps() as CCTimetableLessonNodeShape['props']
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
@ -27,7 +27,7 @@ export class CCUserTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCUserTi
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCUserTimetableLessonNodeShape) => {
renderContent = (shape: CCTimetableLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (

View File

@ -14,8 +14,8 @@ const stateProps = T.object({
const graphBaseProps = {
...baseShapeProps,
__primarylabel__: T.string,
unique_id: T.string,
tldraw_snapshot: T.string,
uuid_string: T.string,
node_storage_path: T.string,
created: T.string,
merged: T.string,
state: T.optional(stateProps.nullable()),
@ -84,9 +84,8 @@ export const ccGraphShapeProps = {
},
'cc-school-node': {
...graphBaseProps,
school_uuid: T.string,
school_name: T.string,
school_website: T.string,
name: T.string,
website: T.string,
},
'cc-department-node': {
...graphBaseProps,
@ -283,8 +282,8 @@ export const getDefaultBaseProps = () => ({
backgroundColor: '#f0f0f0' as string,
title: 'Untitled' as string,
isLocked: false as boolean,
unique_id: '' as string,
tldraw_snapshot: '' as string,
uuid_string: '' as string,
node_storage_path: '' as string,
created: '' as string,
merged: '' as string,
state: {
@ -383,9 +382,8 @@ export const getDefaultCCSchoolNodeProps = () => ({
...getDefaultBaseProps(),
title: 'School',
__primarylabel__: 'School',
school_uuid: '',
school_name: '',
school_website: '',
name: '',
website: '',
})
export const getDefaultCCDepartmentNodeProps = () => ({
@ -642,16 +640,3 @@ export const getDefaultCCUserTeacherTimetableNodeProps = () => ({
school_db_name: '',
school_timetable_id: '',
})
export const getDefaultCCUserTimetableLessonNodeProps = () => ({
...getDefaultBaseProps(),
title: 'User Timetable Lesson',
__primarylabel__: 'UserTimetableLesson',
subject_class: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
school_db_name: '',
school_period_id: '',
})

View File

@ -35,7 +35,7 @@ import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CC
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
import { CCUserTimetableLessonNodeShape, CCUserTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
// Create a const object with all node types
export const NODE_SHAPE_TYPES = {
@ -75,7 +75,7 @@ export const NODE_SHAPE_TYPES = {
PLANNED_LESSON: CCPlannedLessonNodeShapeUtil.type,
DEPARTMENT_STRUCTURE: CCDepartmentStructureNodeShapeUtil.type,
USER_TEACHER_TIMETABLE: CCUserTeacherTimetableNodeShapeUtil.type,
USER_TIMETABLE_LESSON: CCUserTimetableLessonNodeShapeUtil.type,
USER_TIMETABLE_LESSON: CCTimetableLessonNodeShapeUtil.type,
} as const;
// Create the type from the const object's values
@ -119,7 +119,7 @@ export type AllNodeShapes =
| CCPlannedLessonNodeShape
| CCDepartmentStructureNodeShape
| CCUserTeacherTimetableNodeShape
| CCUserTimetableLessonNodeShape;
| CCTimetableLessonNodeShape;
// Export all shape utils in an object for easy access
export const ShapeUtils = {
@ -159,7 +159,7 @@ export const ShapeUtils = {
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
[CCUserTimetableLessonNodeShapeUtil.type]: CCUserTimetableLessonNodeShapeUtil,
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
} as const;
// Add a type guard to check if a shape is a valid node shape

View File

@ -177,8 +177,8 @@ export const checkDefaultComponent = (defaultComponent: boolean | { action: { la
// Base component for all graph nodes
interface DefaultNodeComponentProps {
tldraw_snapshot: string
onInspect?: (tldraw_snapshot: string) => void
node_storage_path: string
onInspect?: (node_storage_path: string) => void
customAction?: {
label: string
handler: () => void
@ -186,13 +186,13 @@ interface DefaultNodeComponentProps {
}
export const DefaultNodeComponent: React.FC<DefaultNodeComponentProps> = ({
tldraw_snapshot,
onInspect = () => console.log(`Inspecting node at path: ${tldraw_snapshot}`),
node_storage_path,
onInspect = () => console.log(`Inspecting node at path: ${node_storage_path}`),
customAction
}) => {
return (
<div style={SHARED_NODE_STYLES.defaultComponent.container}>
<button style={SHARED_NODE_STYLES.defaultComponent.button} onClick={() => onInspect(tldraw_snapshot)}>
<button style={SHARED_NODE_STYLES.defaultComponent.button} onClick={() => onInspect(node_storage_path)}>
Inspect
</button>
{customAction && (

View File

@ -15,8 +15,8 @@ export interface ShapeState {
export type CCGraphShapeProps = CCBaseProps & {
__primarylabel__: string
unique_id: string
tldraw_snapshot: string
uuid_string: string
node_storage_path: string
created: string
merged: string
state: ShapeState | null | undefined
@ -26,8 +26,8 @@ export type CCGraphShapeProps = CCBaseProps & {
// Define the base shape type for graph shapes
export type CCGraphShape = CCBaseShape & TLBaseShape<GraphShapeType, {
__primarylabel__: CCGraphShapeProps['__primarylabel__']
unique_id: CCGraphShapeProps['unique_id']
tldraw_snapshot: CCGraphShapeProps['tldraw_snapshot']
uuid_string: CCGraphShapeProps['uuid_string']
node_storage_path: CCGraphShapeProps['node_storage_path']
created: CCGraphShapeProps['created']
merged: CCGraphShapeProps['merged']
state: CCGraphShapeProps['state']
@ -95,9 +95,8 @@ export type CCCalendarTimeChunkNodeProps = CCGraphShapeProps & {
}
export type CCSchoolNodeProps = CCGraphShapeProps & {
school_uuid: string
school_name: string
school_website: string
name: string
website: string
}
export type CCDepartmentNodeProps = CCGraphShapeProps & {
@ -195,14 +194,6 @@ export type CCTeacherTimetableNodeProps = CCGraphShapeProps & {
end_date: string
}
export type CCTimetableLessonNodeProps = CCGraphShapeProps & {
subject_class: string
date: string
start_time: string
end_time: string
period_code: string
}
export type CCPlannedLessonNodeProps = CCGraphShapeProps & {
date: string
start_time: string
@ -277,7 +268,7 @@ export type CCUserTeacherTimetableNodeProps = CCGraphShapeProps & {
school_timetable_id: string
}
export type CCUserTimetableLessonNodeProps = CCGraphShapeProps & {
export type CCTimetableLessonNodeProps = CCGraphShapeProps & {
subject_class: string
date: string
start_time: string
@ -324,7 +315,7 @@ export type CCNodeTypes = {
SubjectClass: { props: CCSubjectClassNodeProps }
DepartmentStructure: { props: CCDepartmentStructureNodeProps }
UserTeacherTimetable: { props: CCUserTeacherTimetableNodeProps }
UserTimetableLesson: { props: CCUserTimetableLessonNodeProps }
UserTimetableLesson: { props: CCTimetableLessonNodeProps }
}
// Helper function to get shape type from node type
@ -334,7 +325,7 @@ export const getShapeType = (nodeType: keyof CCNodeTypes): string => {
// Helper function to get allowed props from node type
export const getAllowedProps = (): string[] => {
return ['__primarylabel__', 'unique_id'];
return ['__primarylabel__', 'uuid_string'];
}
// Helper function to get node configuration

View File

@ -52,7 +52,7 @@ export const graphState = {
const updatedShapeIds: string[] = [];
nodes.forEach((node, index) => {
if (!node.props?.unique_id) return;
if (!node.props?.uuid_string) return;
const row = Math.floor(index / gridColumns);
const col = index % gridColumns;
@ -60,13 +60,13 @@ export const graphState = {
const x = startX + (col * (GRID_CELL_SIZE + GRID_PADDING));
const y = startY + (row * (GRID_CELL_SIZE + GRID_PADDING));
const shapeId = createShapeId(node.props.unique_id);
const shapeId = createShapeId(node.props.uuid_string);
updatedShapeIds.push(shapeId.toString());
// Update both our internal state and the editor
node.x = x;
node.y = y;
graphState.nodeData.set(node.props.unique_id, node);
graphState.nodeData.set(node.props.uuid_string, node);
// Only create if the shape doesn't exist in our tracking
if (!graphState.shapeIds.has(shapeId.toString())) {
@ -123,12 +123,12 @@ export const graphState = {
addNode: (shape: AllNodeShapes) => {
logger.debug('graphStateUtil', '🔍 Adding shape to graphState:', { shape });
if (!shape.props?.unique_id || !shape.type) {
if (!shape.props?.uuid_string || !shape.type) {
logger.error('graphStateUtil', '❌ Invalid shape data', { shape });
return;
}
const id = shape.props.unique_id;
const id = shape.props.uuid_string;
const shapeId = createShapeId(id).toString();
// Track the shape ID
@ -208,7 +208,7 @@ export const graphState = {
getShapeByUniqueId: (uniqueId: string) => {
return Array.from(graphState.nodeData.values()).find(
shape => shape.props?.unique_id === uniqueId
shape => shape.props?.uuid_string === uniqueId
);
},

View File

@ -1,246 +1,147 @@
import { TLRecord, TLShape } from '@tldraw/tldraw'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence, createBindingPropsMigrationIds, createBindingPropsMigrationSequence } from '@tldraw/tldraw'
import { getDefaultCCBaseProps, getDefaultCCCalendarProps, getDefaultCCLiveTranscriptionProps, getDefaultCCSettingsProps, getDefaultCCSlideProps, getDefaultCCSlideShowProps, getDefaultCCSlideLayoutBindingProps, getDefaultCCYoutubeEmbedProps, getDefaultCCSearchProps, getDefaultCCWebBrowserProps } from './cc-props'
// Export both shape and binding migrations
export const ccBindingMigrations = {
'cc-slide-layout': {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'binding') return record
if (record.type !== 'cc-slide-layout') return record
'cc-slide-layout': createBindingPropsMigrationSequence({
sequence: [
{
id: createBindingPropsMigrationIds('cc-slide-layout', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...record,
props: {
...getDefaultCCSlideLayoutBindingProps(),
...record.props,
},
...getDefaultCCSlideLayoutBindingProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
}
export const ccShapeMigrations = {
base: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-base') return record
base: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-base', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCBaseProps(),
...shape.props,
},
...getDefaultCCBaseProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
calendar: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-calendar') return record
calendar: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-calendar', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCCalendarProps(),
...shape.props,
},
...getDefaultCCCalendarProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
liveTranscription: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-live-transcription') return record
liveTranscription: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-live-transcription', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCLiveTranscriptionProps(),
...shape.props,
},
...getDefaultCCLiveTranscriptionProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
settings: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-settings') return record
settings: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-settings', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCSettingsProps(),
...shape.props,
},
...getDefaultCCSettingsProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
slideshow: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-slideshow') return record
slideshow: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-slideshow', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCSlideShowProps(),
...shape.props,
},
...getDefaultCCSlideShowProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
slide: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-slide') return record
slide: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-slide', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCSlideProps(),
...shape.props,
},
...getDefaultCCSlideProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
'cc-youtube-embed': {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-youtube-embed') return record
'cc-youtube-embed': createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-youtube-embed', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCYoutubeEmbedProps(),
...shape.props,
},
...getDefaultCCYoutubeEmbedProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
search: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-search') return record
search: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-search', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCSearchProps(),
...shape.props,
},
...getDefaultCCSearchProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
webBrowser: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-web-browser') return record
webBrowser: createShapePropsMigrationSequence({
sequence: [
{
id: createShapePropsMigrationIds('cc-web-browser', { Initial: 1 }).Initial,
up: (props: Record<string, unknown>) => {
return {
...shape,
props: {
...getDefaultCCWebBrowserProps(),
...shape.props,
},
...getDefaultCCWebBrowserProps(),
...props,
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
],
}),
}

View File

@ -38,7 +38,7 @@ export const ccShapeProps = {
subjectClass: T.string,
color: T.string,
periodCode: T.string,
tldraw_snapshot: T.string.optional()
node_storage_path: T.string.optional()
})
})),
},

View File

@ -50,12 +50,12 @@ export const createUserNodeFromProfile = (
...getDefaultCCUserNodeProps(),
headerColor: theme.headerColor,
title: userNode.user_email,
unique_id: userNode.unique_id,
uuid_string: userNode.uuid_string,
user_name: userNode.user_name,
user_email: userNode.user_email,
user_type: userNode.user_type,
user_id: userNode.user_id,
path: userNode.tldraw_snapshot,
node_storage_path: userNode.node_storage_path,
worker_node_data: userNode.worker_node_data,
state: {
parentId: null,

View File

@ -1,3 +1,5 @@
console.log('🔍 SCHEMA FILE: Starting schema file execution');
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema';
import { createTLSchemaFromUtils, defaultBindingUtils, defaultShapeUtils } from '@tldraw/tldraw';
import { ShapeUtils } from './shapes';
@ -7,26 +9,33 @@ import { ccGraphMigrations } from './cc-base/cc-graph/cc-graph-migrations';
import { GraphShapeType } from './cc-base/cc-graph/cc-graph-types';
// Create schema with shape definitions
const customShapes = {
...defaultShapeSchemas,
// Dynamically generate shape schemas from ShapeUtils
...Object.values(ShapeUtils).reduce((acc, util) => ({
...acc,
[util.type]: {
props: util.props,
migrations: util.migrations,
}
}), {}),
// Add graph shapes
...(ccGraphShapeProps ? Object.entries(ccGraphShapeProps).reduce((acc, [type, props]) => ({
...acc,
[type]: {
props,
migrations: ccGraphMigrations[type as GraphShapeType],
}
}), {}) : {})
};
// Debug: Log the custom shapes being added
console.log('🔍 SCHEMA DEBUG: Custom shapes in schema:', Object.keys(customShapes).filter(key => key.startsWith('cc-')));
console.log('🔍 SCHEMA DEBUG: ShapeUtils types:', Object.values(ShapeUtils).map(util => util.type));
console.log('🔍 SCHEMA DEBUG: ccGraphShapeProps types:', Object.keys(ccGraphShapeProps || {}));
export const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
// Dynamically generate shape schemas from ShapeUtils
...Object.values(ShapeUtils).reduce((acc, util) => ({
...acc,
[util.type]: {
props: util.props,
migrations: util.migrations,
}
}), {}),
// Add graph shapes
...(ccGraphShapeProps ? Object.entries(ccGraphShapeProps).reduce((acc, [type, props]) => ({
...acc,
[type]: {
props,
migrations: ccGraphMigrations[type as GraphShapeType],
}
}), {}) : {})
},
shapes: customShapes,
bindings: {
...defaultBindingSchemas,
// Add binding schemas from our custom binding utils
@ -40,6 +49,10 @@ export const customSchema = createTLSchema({
},
});
// Debug: Log the final schema sequences
console.log('🔍 SCHEMA DEBUG: Final schema sequences:', customSchema.serialize().sequences);
console.log('🔍 SCHEMA DEBUG: Custom shape sequences:', Object.keys(customSchema.serialize().sequences).filter(key => key.includes('cc-')));
// Create schema from utils (alternative approach)
export const schemaFromUtils = createTLSchemaFromUtils({
shapeUtils: [

View File

@ -41,7 +41,6 @@ import { CCAcademicPeriodNodeShapeUtil } from './cc-base/cc-graph/CCAcademicPeri
import { CCRegistrationPeriodNodeShapeUtil } from './cc-base/cc-graph/CCRegistrationPeriodNodeShapeUtil'
import { CCDepartmentStructureNodeShapeUtil } from './cc-base/cc-graph/CCDepartmentStructureNodeShapeUtil'
import { CCUserTeacherTimetableNodeShapeUtil } from './cc-base/cc-graph/CCUserTeacherTimetableNodeShapeUtil'
import { CCUserTimetableLessonNodeShapeUtil } from './cc-base/cc-graph/CCUserTimetableLessonNodeShapeUtil'
import { CCSearchShapeUtil } from './cc-base/cc-search/CCSearchShapeUtil'
import { CCWebBrowserShapeUtil } from './cc-base/cc-web-browser/CCWebBrowserUtil'
// Define all shape utils in a single object for easy maintenance
@ -88,7 +87,6 @@ export const ShapeUtils = {
CCRegistrationPeriodNode: CCRegistrationPeriodNodeShapeUtil,
CCDepartmentStructureNode: CCDepartmentStructureNodeShapeUtil,
CCUserTeacherTimetableNode: CCUserTeacherTimetableNodeShapeUtil,
CCUserTimetableLessonNode: CCUserTimetableLessonNodeShapeUtil,
CCSearch: CCSearchShapeUtil,
CCWebBrowser: CCWebBrowserShapeUtil,
}

View File

@ -27,6 +27,8 @@ import {
} from '@mui/icons-material';
import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel';
import { CCFilesPanel } from './CCFilesPanel';
import { CCCabinetsPanel } from './CCCabinetsPanel';
import { CCYoutubePanel } from './CCYoutubePanel';
import { CCGraphPanel } from './CCGraphPanel';
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
@ -40,8 +42,10 @@ import { useTLDraw } from '../../../../../contexts/TLDrawContext';
export const PANEL_TYPES = {
default: [
{ id: 'cabinets', label: 'Cabinets', order: 5 },
{ id: 'navigation', label: 'Navigation', order: 10 },
{ id: 'node-snapshot', label: 'Node', order: 20 },
{ id: 'files', label: 'Files', order: 25 },
{ id: 'cc-shapes', label: 'Shapes', order: 30 },
{ id: 'slides', label: 'Slides', order: 40 },
{ id: 'youtube', label: 'YouTube', order: 50 },
@ -111,7 +115,7 @@ const StyledMenuItem = styled(MenuItem)(() => ({
}));
export const BasePanel: React.FC<BasePanelProps> = ({
initialPanelType = 'cc-shapes',
initialPanelType = 'files',
examMarkerProps,
isExpanded: controlledIsExpanded,
isPinned: controlledIsPinned,
@ -151,8 +155,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
);
// Use controlled state if provided, otherwise use internal state
const [internalIsExpanded, setInternalIsExpanded] = React.useState(false);
const [internalIsPinned, setInternalIsPinned] = React.useState(false);
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
const isPinned = controlledIsPinned ?? internalIsPinned;
@ -200,6 +204,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
const getIconForPanel = (panelId: PanelType) => {
switch (panelId) {
case 'cabinets':
return <NavigationIcon />;
case 'cc-shapes':
return <ShapesIcon />;
case 'slides':
@ -223,6 +229,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
const getDescriptionForPanel = (panelId: PanelType) => {
switch (panelId) {
case 'cabinets':
return 'Manage file cabinets';
case 'cc-shapes':
return 'Add shapes and elements to your canvas';
case 'slides':
@ -250,6 +258,10 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}
switch (currentPanelType) {
case 'cabinets':
return <CCCabinetsPanel />;
case 'files':
return <CCFilesPanel />;
case 'cc-shapes':
return <CCShapesPanel />;
case 'slides':

View File

@ -0,0 +1,129 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ThemeProvider, createTheme, useMediaQuery, Box, Grid, Card, CardContent, CardActions, Typography, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, styled } from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
import { supabase } from '../../../../../supabaseClient';
type Cabinet = { id: string; name: string };
const Toolbar = styled('div')(() => ({ display: 'flex', gap: '8px', marginBottom: '8px' }));
export const CCCabinetsPanel: React.FC = () => {
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState<null | Cabinet>(null);
const [newName, setNewName] = useState('');
const theme = useMemo(() => {
const mode = (tldrawPreferences?.colorScheme === 'system')
? (prefersDarkMode ? 'dark' : 'light')
: (tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light');
return createTheme({ palette: { mode, divider: 'var(--color-divider)' } });
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const API_BASE: string = (import.meta as unknown as { env?: { VITE_API_BASE?: string } })?.env?.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record<string, string> } | undefined;
const apiFetch = async (url: string, init?: RequestInitLite) => {
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const { data: { session } } = await supabase.auth.getSession();
const bearer = session?.access_token || authToken || '';
const res = await fetch(fullUrl, {
...init,
headers: {
'Authorization': `Bearer ${bearer}`,
...(init?.headers || {})
}
});
if (!res.ok) throw new Error(await res.text());
return res.json();
};
const loadCabinets = async () => {
const data = await apiFetch('/database/cabinets');
setCabinets([...(data.owned || []), ...(data.shared || [])]);
};
useEffect(() => { loadCabinets(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, []);
const handleCreate = async () => {
if (!newName.trim()) return;
await apiFetch('/database/cabinets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) });
setNewName('');
setCreateOpen(false);
await loadCabinets();
};
const handleRename = async () => {
if (!renameOpen || !newName.trim()) return;
await apiFetch(`/database/cabinets/${renameOpen.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) });
setRenameOpen(null);
setNewName('');
await loadCabinets();
};
const handleDelete = async (cabinetId: string) => {
await apiFetch(`/database/cabinets/${cabinetId}`, { method: 'DELETE' });
await loadCabinets();
};
return (
<ThemeProvider theme={theme}>
<Box sx={{ p: 1, height: '100%', display: 'flex', flexDirection: 'column', gap: 1 }}>
<Toolbar>
<Button size="small" variant="outlined" startIcon={<AddIcon/>} onClick={() => { setNewName(''); setCreateOpen(true); }}>New Cabinet</Button>
</Toolbar>
<Grid container spacing={1} sx={{ overflow: 'auto' }}>
{cabinets.map(c => (
<Grid item xs={12} key={c.id}>
<Card variant="outlined">
<CardContent sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography variant="subtitle1" sx={{ color: 'var(--color-text)' }}>{c.name}</Typography>
<Typography variant="caption" sx={{ color: 'var(--color-text-secondary)' }}>{c.id}</Typography>
</div>
<CardActions>
<IconButton size="small" onClick={() => { setRenameOpen(c); setNewName(c.name); }} title="Rename">
<EditIcon />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(c.id)} title="Delete">
<DeleteIcon />
</IconButton>
</CardActions>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)}>
<DialogTitle>Create Cabinet</DialogTitle>
<DialogContent>
<TextField autoFocus fullWidth label="Name" value={newName} onChange={(e) => setNewName(e.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!newName.trim()}>Create</Button>
</DialogActions>
</Dialog>
<Dialog open={!!renameOpen} onClose={() => setRenameOpen(null)}>
<DialogTitle>Rename Cabinet</DialogTitle>
<DialogContent>
<TextField autoFocus fullWidth label="New name" value={newName} onChange={(e) => setNewName(e.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={() => setRenameOpen(null)}>Cancel</Button>
<Button onClick={handleRename} disabled={!newName.trim()}>Save</Button>
</DialogActions>
</Dialog>
</Box>
</ThemeProvider>
);
};

View File

@ -0,0 +1,863 @@
import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import {
ThemeProvider,
createTheme,
useMediaQuery,
Button,
List,
ListItem,
ListItemText,
IconButton,
styled,
CircularProgress,
Divider,
Menu,
MenuItem,
Box,
Typography,
TextField,
Select,
FormControl,
InputLabel,
Pagination,
Stack,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Paper,
Alert,
LinearProgress
} from '@mui/material';
import UploadIcon from '@mui/icons-material/Upload';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import DeleteIcon from '@mui/icons-material/Delete';
import RefreshIcon from '@mui/icons-material/Refresh';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ImageIcon from '@mui/icons-material/Image';
import DescriptionIcon from '@mui/icons-material/Description';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
import { supabase } from '../../../../../supabaseClient';
import { useNavigate } from 'react-router-dom';
import {
calculateDirectoryStats,
isDirectoryPickerSupported,
FileWithPath
} from '../../../../../utils/folderPicker';
const Container = styled('div')(() => ({
padding: '8px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
height: '100%'
}));
type Cabinet = { id: string; name: string };
type FileRow = {
id: string;
name: string;
mime_type?: string;
is_directory?: boolean;
size_bytes?: number;
processing_status?: string;
relative_path?: string;
created_at?: string;
};
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
interface PaginationInfo {
page: number;
per_page: number;
total_count: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
offset: number;
}
interface FileListResponse {
files: FileRow[];
pagination: PaginationInfo;
filters: {
search?: string;
sort_by: string;
sort_order: string;
include_directories: boolean;
parent_directory_id?: string;
};
}
export const CCFilesPanel: React.FC = () => {
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
const [files, setFiles] = useState<FileRow[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [loading, setLoading] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<null | { el: HTMLElement; fileId: string }>(null);
const [artefacts, setArtefacts] = useState<Artefact[]>([]);
// Pagination and filtering state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(15); // Slightly more for main panel
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const previousSearchTerm = useRef(searchTerm);
// Directory navigation state
const [currentDirectoryId, setCurrentDirectoryId] = useState<string | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([
{ id: null, name: 'Root' }
]);
// Directory upload state
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
const [showDirectoryDialog, setShowDirectoryDialog] = useState(false);
const [isDirectoryUploading, setIsDirectoryUploading] = useState(false);
const [directoryStats, setDirectoryStats] = useState<{
fileCount: number;
directoryCount: number;
totalSize: number;
formattedSize: string;
} | null>(null);
const navigate = useNavigate();
const theme = useMemo(() => {
const mode = (tldrawPreferences?.colorScheme === 'system')
? (prefersDarkMode ? 'dark' : 'light')
: (tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light');
return createTheme({ palette: { mode, divider: 'var(--color-divider)' } });
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
type RequestInitLike = { method?: string; body?: FormData | string | Blob | null; headers?: Record<string, string> } | undefined;
type HeadersInitLike = Record<string, string>;
const API_BASE: string = (import.meta as unknown as { env?: { VITE_API_BASE?: string } })?.env?.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => {
const headers: HeadersInitLike = {
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`,
...(init?.headers || {})
};
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const res = await fetch(fullUrl, { ...(init || {}), headers });
if (!res.ok) throw new Error(await res.text());
return res.json();
}, [authToken, API_BASE]);
const loadCabinets = useCallback(async () => {
setLoading(true);
try {
const data = await apiFetch('/database/cabinets');
const all = [...(data.owned || []), ...(data.shared || [])];
setCabinets(all);
if (all.length && !selectedCabinet) setSelectedCabinet(all[0].id);
} catch (error) {
console.error('Failed to load cabinets:', error);
} finally {
setLoading(false);
}
}, [selectedCabinet, apiFetch]);
const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => {
if (!cabinetId) return;
setLoading(true);
try {
// Build query parameters for pagination, search, and sorting
const params = new URLSearchParams({
cabinet_id: cabinetId,
page: page.toString(),
per_page: itemsPerPage.toString(),
sort_by: sortBy,
sort_order: sortOrder,
include_directories: 'true'
});
// Add directory filtering
if (currentDirectoryId) {
params.append('parent_directory_id', currentDirectoryId);
}
if (searchTerm) {
params.append('search', searchTerm);
}
// Use the new simple upload endpoint for listing files with pagination
const data: FileListResponse = await apiFetch(`/simple-upload/files?${params.toString()}`);
setFiles(data.files || []);
setPagination(data.pagination);
} catch (error) {
console.error('Failed to load files:', error);
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]);
useEffect(() => {
loadCabinets();
}, [loadCabinets]);
// Main loading effect - handles pagination, sorting, cabinet changes, directory navigation
useEffect(() => {
if (selectedCabinet) {
loadFiles(selectedCabinet, currentPage);
}
}, [selectedCabinet, loadFiles, currentPage, itemsPerPage, sortBy, sortOrder, currentDirectoryId]);
// Reset to page 1 and root directory when cabinet changes
useEffect(() => {
if (selectedCabinet) {
setCurrentPage(1);
setCurrentDirectoryId(null);
setBreadcrumbs([{ id: null, name: 'Root' }]);
}
}, [selectedCabinet]);
// Search with debouncing - only when search term actually changes
useEffect(() => {
if (selectedCabinet && searchTerm !== previousSearchTerm.current) {
previousSearchTerm.current = searchTerm;
const timeoutId = setTimeout(() => {
setCurrentPage(1); // Reset to first page when searching
loadFiles(selectedCabinet, 1);
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}
}, [searchTerm, selectedCabinet, loadFiles]);
// Directory navigation handlers
const navigateToFolder = useCallback((folder: FileRow) => {
if (!folder.is_directory) return;
setCurrentDirectoryId(folder.id);
setCurrentPage(1); // Reset to first page when entering folder
// Add to breadcrumbs
setBreadcrumbs(prev => [...prev, { id: folder.id, name: folder.name }]);
}, []);
const navigateToBreadcrumb = useCallback((targetBreadcrumb: { id: string | null; name: string }) => {
setCurrentDirectoryId(targetBreadcrumb.id);
setCurrentPage(1); // Reset to first page
// Trim breadcrumbs to the selected one
setBreadcrumbs(prev => {
const targetIndex = prev.findIndex(b => b.id === targetBreadcrumb.id && b.name === targetBreadcrumb.name);
return targetIndex !== -1 ? prev.slice(0, targetIndex + 1) : [{ id: null, name: 'Root' }];
});
}, []);
// Sort files to group directories first, then regular files
const sortedFiles = useMemo(() => {
return [...files].sort((a, b) => {
// Directories come first
if (a.is_directory && !b.is_directory) return -1;
if (!a.is_directory && b.is_directory) return 1;
// Within the same type (both directories or both files), sort alphabetically by name
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
});
}, [files]);
// Check if we need a separator between directories and files
const needsGroupSeparator = useMemo(() => {
const hasDirectories = sortedFiles.some(f => f.is_directory);
const hasFiles = sortedFiles.some(f => !f.is_directory);
return hasDirectories && hasFiles;
}, [sortedFiles]);
const getGroupSeparatorIndex = useMemo(() => {
if (!needsGroupSeparator) return -1;
return sortedFiles.findIndex(f => !f.is_directory) - 1;
}, [sortedFiles, needsGroupSeparator]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || !selectedCabinet) return;
const file = e.target.files[0];
await uploadFile(file);
(e.target as HTMLInputElement).value = '';
};
const handleDirectorySelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || !selectedCabinet) return;
// Convert FileList to FileWithPath array with relative paths
const files: FileWithPath[] = [];
Array.from(e.target.files).forEach(file => {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
(file as FileWithPath).relativePath = relativePath;
files.push(file as FileWithPath);
});
if (files.length > 0) {
prepareDirectoryUpload(files);
}
(e.target as HTMLInputElement).value = '';
};
const uploadFile = async (file: File) => {
if (!selectedCabinet) return;
const form = new FormData();
form.append('cabinet_id', selectedCabinet);
form.append('path', file.name);
form.append('scope', 'teacher');
form.append('file', file);
await apiFetch('/database/files/upload', { method: 'POST', body: form });
await loadFiles(selectedCabinet);
};
const prepareDirectoryUpload = (files: FileWithPath[]) => {
if (files.length === 0) return;
setSelectedFiles(files);
setDirectoryStats(calculateDirectoryStats(files));
setShowDirectoryDialog(true);
};
const startDirectoryUpload = async () => {
if (!selectedCabinet || selectedFiles.length === 0) return;
setIsDirectoryUploading(true);
try {
const firstFilePath = selectedFiles[0].relativePath;
const directoryName = firstFilePath.split('/')[0] || 'uploaded-folder';
const formData = new FormData();
formData.append('cabinet_id', selectedCabinet);
formData.append('scope', 'teacher');
formData.append('directory_name', directoryName);
selectedFiles.forEach(file => {
formData.append('files', file);
});
const relativePaths = selectedFiles.map(f => f.relativePath);
formData.append('file_paths', JSON.stringify(relativePaths));
await apiFetch('/simple-upload/files/upload-directory', {
method: 'POST',
body: formData
});
await loadFiles(selectedCabinet);
setShowDirectoryDialog(false);
setSelectedFiles([]);
setDirectoryStats(null);
} catch (error) {
console.error('Directory upload failed:', error);
} finally {
setIsDirectoryUploading(false);
}
};
const handleDelete = async (fileId: string) => {
await apiFetch(`/database/files/${fileId}`, { method: 'DELETE' });
await loadFiles(selectedCabinet);
};
const handleGenerateInitial = async (fileId: string) => {
await apiFetch(`/database/files/${fileId}/artefacts/initial`, { method: 'POST' });
const arts = await apiFetch(`/database/files/${fileId}/artefacts`);
setArtefacts(arts || []);
};
const openMenu = (el: HTMLElement, fileId: string) => setMenuAnchor({ el, fileId });
const closeMenu = () => setMenuAnchor(null);
const goToAIContent = () => {
if (!menuAnchor) return;
const fileId = menuAnchor.fileId;
closeMenu();
navigate(`/doc-intelligence/${encodeURIComponent(fileId)}`);
};
const iconForMime = (mime?: string, isDirectory?: boolean) => {
if (isDirectory) return <FolderIcon />;
if (!mime) return <InsertDriveFileIcon/>;
if (mime.startsWith('image/')) return <ImageIcon/>;
if (mime === 'application/pdf' || mime.startsWith('application/')) return <DescriptionIcon/>;
return <InsertDriveFileIcon/>;
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getStatusColor = (status?: string) => {
switch (status) {
case 'uploaded': return 'primary';
case 'processing': return 'warning';
case 'completed': return 'success';
case 'failed': return 'error';
default: return 'default';
}
};
return (
<ThemeProvider theme={theme}>
<Container>
{/* Cabinet Selection Dropdown */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
<FormControl size="small" fullWidth>
<InputLabel>Cabinet</InputLabel>
<Select
value={selectedCabinet}
label="Cabinet"
onChange={(e) => setSelectedCabinet(e.target.value)}
startAdornment={<FolderIcon sx={{ color: 'action.active', mr: 1, fontSize: '1rem' }} />}
>
{cabinets.map(c => (
<MenuItem key={c.id} value={c.id}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<Typography variant="body2">{c.name}</Typography>
{pagination && selectedCabinet === c.id && (
<Chip label={`${pagination.total_count} files`} size="small" sx={{ ml: 1 }} />
)}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<Button
size="small"
variant="outlined"
onClick={() => {
setCurrentPage(1);
setSearchTerm('');
loadCabinets();
}}
sx={{
minWidth: 40,
width: 40,
height: 40, // Match the height of Select components
padding: 0,
'& .MuiButton-startIcon': {
margin: 0
}
}}
>
<RefreshIcon fontSize="small" />
</Button>
</Box>
{/* Search Box - Full Width */}
<Box sx={{ mb: 1 }}>
<TextField
size="small"
label="Search files"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth
placeholder="Type to search files..."
/>
</Box>
{/* Sort and Filter Controls */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center', py: 1 }}>
<FormControl size="small" sx={{ minWidth: 80 }}>
<InputLabel>Sort</InputLabel>
<Select
value={sortBy}
label="Sort"
onChange={(e) => setSortBy(e.target.value)}
>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="created_at">Date</MenuItem>
<MenuItem value="size_bytes">Size</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 60 }}>
<InputLabel>Order</InputLabel>
<Select
value={sortOrder}
label="Order"
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
>
<MenuItem value="asc"></MenuItem>
<MenuItem value="desc"></MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 60 }}>
<InputLabel>Per page</InputLabel>
<Select
value={itemsPerPage}
label="Per page"
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={15}>15</MenuItem>
<MenuItem value={25}>25</MenuItem>
</Select>
</FormControl>
</Box>
{/* Breadcrumb Navigation */}
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
py: 1,
px: 1,
bgcolor: 'background.paper',
borderBottom: '1px solid var(--color-divider)'
}}>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={`${breadcrumb.id}-${breadcrumb.name}`}>
{index > 0 && (
<Typography variant="caption" color="textSecondary">
/
</Typography>
)}
<Button
size="small"
variant="text"
onClick={() => navigateToBreadcrumb(breadcrumb)}
sx={{
minWidth: 'auto',
textTransform: 'none',
color: index === breadcrumbs.length - 1 ? 'primary.main' : 'text.secondary',
fontWeight: index === breadcrumbs.length - 1 ? 600 : 400
}}
>
{breadcrumb.name}
</Button>
</React.Fragment>
))}
</Box>
{/* File List with Fixed Height */}
<Box sx={{
border: '1px solid var(--color-divider)',
borderRadius: '4px',
height: 300, // Fixed height for main panel
overflow: 'auto',
flex: 1,
// Hide scrollbar while keeping scroll functionality
scrollbarWidth: 'none', // Firefox
'&::-webkit-scrollbar': {
display: 'none' // WebKit browsers (Chrome, Safari, Edge)
}
}}>
{loading ? (
<Box sx={{ p: 2, textAlign: 'center' }}>
<CircularProgress size={20}/>
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
Loading files...
</Typography>
</Box>
) : sortedFiles.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="textSecondary">
{searchTerm ? 'No files found matching your search.' : 'No files found. Upload some files!'}
</Typography>
</Box>
) : (
<List dense disablePadding>
{sortedFiles.map((f, index) => (
<React.Fragment key={f.id}>
{f.is_directory ? (
<ListItem
button
onClick={() => navigateToFolder(f)}
sx={{
cursor: 'pointer',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
secondaryAction={
<>
<IconButton size="small" onClick={(e) => openMenu(e.currentTarget, f.id)} title="File actions">
<MoreVertIcon/>
</IconButton>
<IconButton edge="end" size="small" onClick={() => handleDelete(f.id)} title="Delete file">
<DeleteIcon/>
</IconButton>
</>
}
>
{iconForMime(f.mime_type, f.is_directory)}
<ListItemText
sx={{ ml: 1 }}
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
{f.name}
</Typography>
{f.is_directory && <Chip label="Dir" size="small" />}
{f.processing_status && f.processing_status !== 'uploaded' && (
<Chip
label={f.processing_status}
size="small"
color={getStatusColor(f.processing_status)}
/>
)}
</Box>
}
secondary={
<Typography variant="caption" color="textSecondary">
{f.size_bytes ? formatFileSize(f.size_bytes) : 'Unknown size'}
{f.mime_type && `${f.mime_type.split('/')[1]}`}
</Typography>
}
/>
</ListItem>
) : (
<ListItem
secondaryAction={
<>
<IconButton size="small" onClick={(e) => openMenu(e.currentTarget, f.id)} title="File actions">
<MoreVertIcon/>
</IconButton>
<IconButton edge="end" size="small" onClick={() => handleDelete(f.id)} title="Delete file">
<DeleteIcon/>
</IconButton>
</>
}
>
{iconForMime(f.mime_type, f.is_directory)}
<ListItemText
sx={{ ml: 1 }}
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
{f.name}
</Typography>
{f.is_directory && <Chip label="Dir" size="small" />}
{f.processing_status && f.processing_status !== 'uploaded' && (
<Chip
label={f.processing_status}
size="small"
color={getStatusColor(f.processing_status)}
/>
)}
</Box>
}
secondary={
<Typography variant="caption" color="textSecondary">
{f.size_bytes ? formatFileSize(f.size_bytes) : 'Unknown size'}
{f.mime_type && `${f.mime_type.split('/')[1]}`}
</Typography>
}
/>
</ListItem>
)}
{/* Group separator between directories and files */}
{index === getGroupSeparatorIndex && needsGroupSeparator && (
<Divider sx={{ my: 1, borderStyle: 'dashed', borderColor: 'divider' }} />
)}
{/* Regular divider between items */}
{index < sortedFiles.length - 1 && index !== getGroupSeparatorIndex && <Divider />}
</React.Fragment>
))}
</List>
)}
</Box>
{/* Pagination Controls */}
{pagination && pagination.total_pages > 1 && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Stack spacing={1} alignItems="center">
<Pagination
count={pagination.total_pages}
page={pagination.page}
onChange={(event, value) => setCurrentPage(value)}
color="primary"
size="small"
showFirstButton
showLastButton
/>
<Typography variant="caption" color="textSecondary">
{pagination.offset + 1}-{Math.min(pagination.offset + pagination.per_page, pagination.total_count)} of {pagination.total_count}
</Typography>
</Stack>
</Box>
)}
{/* Upload Controls */}
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexDirection: 'column' }}>
{/* File Inputs */}
<input
id="cc-file-input"
type="file"
style={{ display: 'none' }}
onChange={handleUpload}
disabled={!selectedCabinet}
/>
<input
id="cc-directory-input"
type="file"
style={{ display: 'none' }}
{...({ webkitdirectory: '' } as React.InputHTMLAttributes<HTMLInputElement>)}
multiple
onChange={handleDirectorySelect}
disabled={!selectedCabinet}
/>
{/* Upload Buttons */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
startIcon={<UploadIcon />}
onClick={() => selectedCabinet && document.getElementById('cc-file-input')?.click()}
disabled={!selectedCabinet}
fullWidth
>
Upload File
</Button>
<Button
variant="outlined"
startIcon={<FolderOpenIcon />}
onClick={() => selectedCabinet && document.getElementById('cc-directory-input')?.click()}
disabled={!selectedCabinet}
fullWidth
>
Upload Folder
</Button>
</Box>
{!selectedCabinet && (
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', mt: 0.5 }}>
Select a cabinet first to enable uploads
</Typography>
)}
{selectedCabinet && !isDirectoryPickerSupported() && (
<Typography variant="caption" color="warning.main" sx={{ textAlign: 'center', mt: 0.5 }}>
Folder uploads may have limited support in this browser
</Typography>
)}
</Box>
<Menu
anchorEl={menuAnchor?.el ?? null}
open={!!menuAnchor}
onClose={closeMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={() => { if (menuAnchor) { handleGenerateInitial(menuAnchor.fileId); closeMenu(); } }}>Generate initial artefacts</MenuItem>
<MenuItem onClick={goToAIContent}>Open AI content</MenuItem>
</Menu>
{artefacts.length > 0 && (
<>
<Divider/>
<List dense sx={{
border: '1px solid var(--color-divider)',
borderRadius: '4px',
overflow: 'auto',
maxHeight: 160,
// Hide scrollbar while keeping scroll functionality
scrollbarWidth: 'none', // Firefox
'&::-webkit-scrollbar': {
display: 'none' // WebKit browsers (Chrome, Safari, Edge)
}
}}>
{artefacts.map(a => (
<ListItem key={a.id}>
<ListItemText primary={a.type} secondary={a.rel_path} />
</ListItem>
))}
</List>
</>
)}
{/* Directory Upload Dialog */}
<Dialog
open={showDirectoryDialog}
onClose={() => !isDirectoryUploading && setShowDirectoryDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<FolderOpenIcon />
Directory Upload
{isDirectoryUploading && <LinearProgress sx={{ flexGrow: 1, ml: 2 }} />}
</Box>
</DialogTitle>
<DialogContent>
{directoryStats && (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>{directoryStats.fileCount} files</strong> in{' '}
<strong>{directoryStats.directoryCount} folders</strong><br/>
Total size: <strong>{directoryStats.formattedSize}</strong>
</Typography>
</Alert>
)}
<Paper variant="outlined" sx={{
p: 2,
maxHeight: 200,
overflow: 'auto',
// Hide scrollbar while keeping scroll functionality
scrollbarWidth: 'none', // Firefox
'&::-webkit-scrollbar': {
display: 'none' // WebKit browsers (Chrome, Safari, Edge)
}
}}>
<Typography variant="body2" color="textSecondary" gutterBottom>
Files to upload:
</Typography>
{selectedFiles.map((file, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="body2" sx={{ flex: 1, mr: 2 }} noWrap>
{file.relativePath}
</Typography>
<Typography variant="caption" color="textSecondary">
{formatFileSize(file.size)}
</Typography>
</Box>
))}
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowDirectoryDialog(false)} disabled={isDirectoryUploading}>
Cancel
</Button>
<Button
onClick={startDirectoryUpload}
variant="contained"
disabled={isDirectoryUploading || selectedFiles.length === 0}
>
{isDirectoryUploading ? 'Uploading...' : 'Upload Directory'}
</Button>
</DialogActions>
</Dialog>
</Container>
</ThemeProvider>
);
};

View File

@ -0,0 +1,505 @@
import React, { useEffect, useMemo, useState, useRef } from 'react';
import {
ThemeProvider,
createTheme,
useMediaQuery,
Button,
List,
ListItem,
ListItemText,
IconButton,
styled,
CircularProgress,
Divider,
Menu,
MenuItem,
Box,
Typography,
LinearProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip,
Tooltip,
Alert
} from '@mui/material';
import UploadIcon from '@mui/icons-material/Upload';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import DeleteIcon from '@mui/icons-material/Delete';
import RefreshIcon from '@mui/icons-material/Refresh';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ImageIcon from '@mui/icons-material/Image';
import DescriptionIcon from '@mui/icons-material/Description';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
import { supabase } from '../../../../../supabaseClient';
import { useNavigate } from 'react-router-dom';
import {
pickDirectory,
processDirectoryFiles,
calculateDirectoryStats,
createDirectoryTree,
formatFileSize,
isDirectoryPickerSupported,
FileWithPath
} from '../../../../folderPicker';
import pLimit from 'p-limit';
const Container = styled('div')(() => ({
padding: '8px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
height: '100%'
}));
const Row = styled('div')(() => ({
display: 'flex',
gap: '8px',
alignItems: 'center'
}));
type Cabinet = { id: string; name: string };
type FileRow = { id: string; name: string; mime_type?: string; is_directory?: boolean; size_bytes?: number };
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
interface UploadProgress {
path: string;
size: number;
status: 'queued' | 'uploading' | 'done' | 'error';
progress: number;
error?: string;
}
export const CCFilesPanelEnhanced: React.FC = () => {
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string };
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
const [files, setFiles] = useState<FileRow[]>([]);
const [loading, setLoading] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<null | { el: HTMLElement; fileId: string }>(null);
const [artefacts, setArtefacts] = useState<Artefact[]>([]);
// Directory upload states
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
const [directoryStats, setDirectoryStats] = useState<any>(null);
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const dirInputRef = useRef<HTMLInputElement>(null);
const theme = useMemo(() => {
const mode = (tldrawPreferences?.colorScheme === 'system')
? (prefersDarkMode ? 'dark' : 'light')
: (tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light');
return createTheme({ palette: { mode, divider: 'var(--color-divider)' } });
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
type RequestInitLike = { method?: string; body?: FormData | string | Blob | null; headers?: Record<string, string> } | undefined;
type HeadersInitLike = Record<string, string>;
const API_BASE: string = (import.meta as unknown as { env?: { VITE_API_BASE?: string } })?.env?.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const apiFetch = async (url: string, init?: RequestInitLike) => {
const headers: HeadersInitLike = {
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`,
...(init?.headers || {})
};
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const res = await fetch(fullUrl, { ...(init || {}), headers });
if (!res.ok) throw new Error(await res.text());
return res.json();
};
const loadCabinets = async () => {
setLoading(true);
try {
const data = await apiFetch('/database/cabinets');
const all = [...(data.owned || []), ...(data.shared || [])];
setCabinets(all);
if (all.length && !selectedCabinet) setSelectedCabinet(all[0].id);
} finally {
setLoading(false);
}
};
const loadFiles = async (cabinetId: string) => {
setLoading(true);
try {
const data = await apiFetch(`/simple-upload/files?cabinet_id=${encodeURIComponent(cabinetId)}`);
setFiles(data.files || []);
} finally {
setLoading(false);
}
};
useEffect(() => { loadCabinets(); }, []);
useEffect(() => { if (selectedCabinet) loadFiles(selectedCabinet); }, [selectedCabinet]);
const handleSingleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || !selectedCabinet) return;
const file = e.target.files[0];
const form = new FormData();
form.append('cabinet_id', selectedCabinet);
form.append('path', file.name);
form.append('scope', 'teacher');
form.append('file', file);
try {
await apiFetch('/simple-upload/files/upload', { method: 'POST', body: form });
await loadFiles(selectedCabinet);
(e.target as HTMLInputElement).value = '';
} catch (error) {
console.error('Upload failed:', error);
alert(`Upload failed: ${error}`);
}
};
const handleDirectoryPicker = async () => {
try {
const files = await pickDirectory();
prepareDirectoryUpload(files);
} catch (error: any) {
if (error.message === 'fallback-input') {
// Use fallback input
dirInputRef.current?.click();
} else if (error.message === 'user-cancelled') {
// User cancelled, do nothing
} else {
console.error('Directory picker error:', error);
alert('Failed to pick directory. Please try the fallback method.');
dirInputRef.current?.click();
}
}
};
const handleFallbackDirectorySelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const files = processDirectoryFiles(e.target.files);
prepareDirectoryUpload(files);
e.target.value = ''; // Reset input
};
const prepareDirectoryUpload = (files: FileWithPath[]) => {
if (files.length === 0) {
alert('No files selected');
return;
}
setSelectedFiles(files);
setDirectoryStats(calculateDirectoryStats(files));
// Initialize upload progress
const progress: UploadProgress[] = files.map(file => ({
path: file.relativePath,
size: file.size,
status: 'queued',
progress: 0
}));
setUploadProgress(progress);
setShowUploadDialog(true);
};
const startDirectoryUpload = async () => {
if (!selectedCabinet || selectedFiles.length === 0) return;
setIsUploading(true);
try {
// Get directory name from first file's path
const firstFilePath = selectedFiles[0].relativePath;
const directoryName = firstFilePath.split('/')[0] || 'uploaded-folder';
// Prepare form data
const formData = new FormData();
formData.append('cabinet_id', selectedCabinet);
formData.append('scope', 'teacher');
formData.append('directory_name', directoryName);
// Add all files
selectedFiles.forEach(file => {
formData.append('files', file);
});
// Add relative paths as JSON
const relativePaths = selectedFiles.map(f => f.relativePath);
formData.append('file_paths', JSON.stringify(relativePaths));
// Upload directory
const result = await apiFetch('/simple-upload/files/upload-directory', {
method: 'POST',
body: formData
});
console.log('Directory upload result:', result);
// Update progress to completed
setUploadProgress(prev => prev.map(item => ({
...item,
status: 'done',
progress: 100
})));
// Refresh file list
await loadFiles(selectedCabinet);
// Close dialog after a short delay
setTimeout(() => {
setShowUploadDialog(false);
setIsUploading(false);
setSelectedFiles([]);
setUploadProgress([]);
}, 2000);
} catch (error) {
console.error('Directory upload failed:', error);
alert(`Directory upload failed: ${error}`);
// Mark all as error
setUploadProgress(prev => prev.map(item => ({
...item,
status: 'error',
error: String(error)
})));
setIsUploading(false);
}
};
const handleDelete = async (fileId: string) => {
try {
await apiFetch(`/simple-upload/files/${fileId}`, { method: 'DELETE' });
await loadFiles(selectedCabinet);
} catch (error) {
console.error('Delete failed:', error);
alert(`Delete failed: ${error}`);
}
};
const handleGenerateInitial = async (fileId: string) => {
// This would trigger manual processing if we implement it later
alert('Manual processing not yet implemented');
};
const openMenu = (el: HTMLElement, fileId: string) => setMenuAnchor({ el, fileId });
const closeMenu = () => setMenuAnchor(null);
const goToAIContent = () => {
if (!menuAnchor) return;
const fileId = menuAnchor.fileId;
closeMenu();
navigate(`/doc-intelligence/${encodeURIComponent(fileId)}`);
};
const iconForMime = (mime?: string, isDirectory?: boolean) => {
if (isDirectory) return <FolderIcon />;
if (!mime) return <InsertDriveFileIcon />;
if (mime.startsWith('image/')) return <ImageIcon />;
if (mime === 'application/pdf' || mime.startsWith('application/')) return <DescriptionIcon />;
return <InsertDriveFileIcon />;
};
const formatFileInfo = (file: FileRow) => {
if (file.is_directory) {
return `Directory • ${file.size_bytes ? formatFileSize(file.size_bytes) : 'Unknown size'}`;
}
return file.size_bytes ? formatFileSize(file.size_bytes) : 'Unknown size';
};
return (
<ThemeProvider theme={theme}>
<Container>
<Row>
<Button size="small" startIcon={<RefreshIcon/>} onClick={loadCabinets}>Refresh</Button>
</Row>
<List dense sx={{ border: '1px solid var(--color-divider)', borderRadius: '4px', overflow: 'auto', maxHeight: 140 }}>
{cabinets.map(c => (
<ListItem key={c.id} selected={c.id === selectedCabinet} onClick={() => setSelectedCabinet(c.id)} sx={{ cursor: 'pointer' }}>
<FolderIcon sx={{ mr: 1 }}/>
<ListItemText primary={c.name} secondary={c.id} />
</ListItem>
))}
</List>
<Divider/>
<Row>
{/* Single file upload */}
<input id="cc-file-input" type="file" style={{ display: 'none' }} onChange={handleSingleUpload}/>
<label htmlFor="cc-file-input">
<Button size="small" variant="outlined" startIcon={<UploadIcon/>} component="span" disabled={!selectedCabinet}>
Upload File
</Button>
</label>
{/* Directory upload */}
<input
ref={dirInputRef}
type="file"
style={{ display: 'none' }}
webkitdirectory=""
multiple
onChange={handleFallbackDirectorySelect}
/>
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
<Button
size="small"
variant="outlined"
startIcon={<FolderOpenIcon/>}
onClick={handleDirectoryPicker}
disabled={!selectedCabinet}
>
Upload Folder
</Button>
</Tooltip>
</Row>
{loading ? <CircularProgress size={20}/> : (
<List dense sx={{ border: '1px solid var(--color-divider)', borderRadius: '4px', overflow: 'auto', flex: 1 }}>
{files.map(f => (
<ListItem key={f.id}
secondaryAction={
<>
<IconButton size="small" onClick={(e) => openMenu(e.currentTarget, f.id)} title="File actions">
<MoreVertIcon/>
</IconButton>
<IconButton edge="end" size="small" onClick={() => handleDelete(f.id)} title="Delete file">
<DeleteIcon/>
</IconButton>
</>
}
>
{iconForMime(f.mime_type, f.is_directory)}
<ListItemText
sx={{ ml: 1 }}
primary={f.name}
secondary={formatFileInfo(f)}
/>
{f.is_directory && <Chip label="Directory" size="small" />}
</ListItem>
))}
</List>
)}
<Menu
anchorEl={menuAnchor?.el ?? null}
open={!!menuAnchor}
onClose={closeMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={() => { if (menuAnchor) { handleGenerateInitial(menuAnchor.fileId); closeMenu(); } }}>
Process manually
</MenuItem>
<MenuItem onClick={goToAIContent}>Open AI content</MenuItem>
</Menu>
{/* Directory Upload Dialog */}
<Dialog open={showUploadDialog} onClose={() => !isUploading && setShowUploadDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<CloudUploadIcon />
Directory Upload
{isUploading && <CircularProgress size={20} />}
</Box>
</DialogTitle>
<DialogContent>
{directoryStats && (
<Box sx={{ mb: 2 }}>
<Alert severity="info">
<Typography variant="body2">
<strong>{directoryStats.fileCount} files</strong> in{' '}
<strong>{directoryStats.directoryCount} folders</strong><br/>
Total size: <strong>{directoryStats.formattedSize}</strong>
</Typography>
</Alert>
</Box>
)}
<Box sx={{ mb: 2 }}>
<Typography variant="h6" gutterBottom>
Upload Progress
</Typography>
{uploadProgress.length > 0 && (
<>
<Box sx={{ mb: 1 }}>
<Typography variant="body2" color="textSecondary">
{uploadProgress.filter(p => p.status === 'done').length} / {uploadProgress.length} files completed
</Typography>
<LinearProgress
variant="determinate"
value={(uploadProgress.filter(p => p.status === 'done').length / uploadProgress.length) * 100}
sx={{ mt: 1 }}
/>
</Box>
<Box sx={{ maxHeight: 300, overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<table style={{ width: '100%', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid', backgroundColor: 'rgba(0,0,0,0.05)' }}>
<th style={{ textAlign: 'left', padding: '8px' }}>Path</th>
<th style={{ textAlign: 'right', padding: '8px' }}>Size</th>
<th style={{ textAlign: 'center', padding: '8px' }}>Status</th>
</tr>
</thead>
<tbody>
{uploadProgress.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<td style={{ padding: '4px 8px', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.path}
</td>
<td style={{ padding: '4px 8px', textAlign: 'right' }}>
{formatFileSize(item.size)}
</td>
<td style={{ padding: '4px 8px', textAlign: 'center' }}>
<Chip
label={item.status}
size="small"
color={
item.status === 'done' ? 'success' :
item.status === 'error' ? 'error' :
item.status === 'uploading' ? 'primary' : 'default'
}
/>
</td>
</tr>
))}
</tbody>
</table>
</Box>
</>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowUploadDialog(false)} disabled={isUploading}>
Cancel
</Button>
<Button
onClick={startDirectoryUpload}
variant="contained"
disabled={isUploading || selectedFiles.length === 0}
startIcon={isUploading ? <CircularProgress size={16} /> : <CloudUploadIcon />}
>
{isUploading ? 'Uploading...' : 'Start Upload'}
</Button>
</DialogActions>
</Dialog>
</Container>
</ThemeProvider>
);
};
export default CCFilesPanelEnhanced;

Some files were not shown because too many files have changed in this diff Show More