latest
This commit is contained in:
parent
69ecf2c7c1
commit
3b4876793e
30
.env
30
.env
@ -1,14 +1,18 @@
|
|||||||
VITE_APP_URL=app.classroomcopilot.ai
|
PORT_FRONTEND=5173
|
||||||
VITE_FRONTEND_SITE_URL=classroomcopilot.ai
|
PORT_FRONTEND_HMR=3002
|
||||||
VITE_APP_PROTOCOL=https
|
PORT_API=800
|
||||||
VITE_APP_NAME=Classroom Copilot
|
PORT_SUPABASE=8000
|
||||||
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
|
|
||||||
|
|
||||||
APP_PROTOCOL=https
|
HOST_FRONTEND=localhost:5173
|
||||||
APP_URL=app.classroomcopilot.ai
|
VITE_PORT_FRONTEND=5173
|
||||||
PORT_FRONTEND=3000
|
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
|
||||||
@ -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
|
|
||||||
@ -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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
.env
|
||||||
@ -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
|
|
||||||
@ -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"]
|
|
||||||
@ -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
107
README.md
@ -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
59
dist/.vite/manifest.json
vendored
Normal 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
12
dist/audioWorklet.js
vendored
Normal 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
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
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
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
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
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
21
dist/icons/sticker-tool.svg
vendored
Normal 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
18
dist/index.html
vendored
Normal 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
1
dist/manifest.webmanifest
vendored
Normal 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
70
dist/offline.html
vendored
Normal 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
1
dist/registerSW.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}
|
||||||
3889
dist/sw.js
vendored
Normal file
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
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
16525
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -39,6 +39,7 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"p-limit": "^7.1.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
"postcss-import": "^16.1.0",
|
"postcss-import": "^16.1.0",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { useAuth } from './contexts/AuthContext';
|
||||||
import { useUser } from './contexts/UserContext';
|
import { useUser } from './contexts/UserContext';
|
||||||
import { useNeoUser } from './contexts/NeoUserContext';
|
import { useNeoUser } from './contexts/NeoUserContext';
|
||||||
@ -22,19 +22,45 @@ import NotFound from './pages/user/NotFound';
|
|||||||
import NotFoundPublic from './pages/NotFoundPublic';
|
import NotFoundPublic from './pages/NotFoundPublic';
|
||||||
import ShareHandler from './pages/tldraw/ShareHandler';
|
import ShareHandler from './pages/tldraw/ShareHandler';
|
||||||
import SearxngPage from './pages/searxngPage';
|
import SearxngPage from './pages/searxngPage';
|
||||||
|
import SimpleUploadTest from './pages/dev/SimpleUploadTest';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
import { CircularProgress } from '@mui/material';
|
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 AppRoutes: React.FC = () => {
|
||||||
const { user, loading: isAuthLoading } = useAuth();
|
const { user, loading: isAuthLoading } = useAuth();
|
||||||
const { isInitialized: isUserInitialized } =
|
|
||||||
useUser();
|
|
||||||
const { isLoading: isNeoUserLoading, isInitialized: isNeoUserInitialized } =
|
|
||||||
useNeoUser();
|
|
||||||
const {
|
|
||||||
isLoading: isNeoInstituteLoading,
|
|
||||||
isInitialized: isNeoInstituteInitialized,
|
|
||||||
} = useNeoInstitute();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Debug log for routing
|
// Debug log for routing
|
||||||
@ -46,27 +72,10 @@ const AppRoutes: React.FC = () => {
|
|||||||
authStatus: {
|
authStatus: {
|
||||||
isLoading: isAuthLoading,
|
isLoading: isAuthLoading,
|
||||||
},
|
},
|
||||||
userStatus: {
|
|
||||||
isInitialized: isUserInitialized,
|
|
||||||
},
|
|
||||||
neoUserStatus: {
|
|
||||||
isLoading: isNeoUserLoading,
|
|
||||||
isInitialized: isNeoUserInitialized,
|
|
||||||
},
|
|
||||||
neoInstituteStatus: {
|
|
||||||
isLoading: isNeoInstituteLoading,
|
|
||||||
isInitialized: isNeoInstituteInitialized,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show loading state while initializing
|
// Show loading state while initializing
|
||||||
if (
|
if (isAuthLoading) {
|
||||||
isAuthLoading ||
|
|
||||||
(user &&
|
|
||||||
(!isUserInitialized ||
|
|
||||||
!isNeoUserInitialized ||
|
|
||||||
!isNeoInstituteInitialized))
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div
|
<div
|
||||||
@ -89,12 +98,18 @@ const AppRoutes: React.FC = () => {
|
|||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={user ? <SinglePlayerPage /> : <TLDrawCanvas />}
|
element={user ? <DashboardPage /> : <TLDrawCanvas />}
|
||||||
/>
|
/>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
<Route path="/share" element={<ShareHandler />} />
|
<Route path="/share" element={<ShareHandler />} />
|
||||||
|
|
||||||
|
{/* Lightweight authenticated routes */}
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={user ? <DashboardPage /> : <Navigate to="/login" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Super Admin only routes */}
|
{/* Super Admin only routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
@ -104,22 +119,21 @@ const AppRoutes: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||||
{user &&
|
{user && (
|
||||||
isUserInitialized &&
|
<Route element={<FullContextRoutes />}>
|
||||||
isNeoUserInitialized &&
|
|
||||||
isNeoInstituteInitialized && (
|
|
||||||
<>
|
|
||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||||
<Route path="/exam-marker" element={<CCExamMarker />} />
|
<Route path="/exam-marker" element={<CCExamMarker />} />
|
||||||
|
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||||
<Route path="/morphic" element={<MorphicPage />} />
|
<Route path="/morphic" element={<MorphicPage />} />
|
||||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||||
<Route path="/dev" element={<DevPage />} />
|
<Route path="/dev" element={<DevPage />} />
|
||||||
|
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
||||||
<Route path="/single-player" element={<SinglePlayerPage />} />
|
<Route path="/single-player" element={<SinglePlayerPage />} />
|
||||||
<Route path="/multiplayer" element={<MultiplayerUser />} />
|
<Route path="/multiplayer" element={<MultiplayerUser />} />
|
||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</>
|
</Route>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fallback route - use different NotFound pages based on auth state */}
|
{/* Fallback route - use different NotFound pages based on auth state */}
|
||||||
@ -130,4 +144,3 @@ const AppRoutes: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default AppRoutes;
|
export default AppRoutes;
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import axios from 'axios';
|
|||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
// Use development backend URL if no custom URL is provided
|
// Use development backend URL if no custom URL is provided
|
||||||
const appProtocol = import.meta.env.VITE_APP_PROTOCOL;
|
const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||||
const baseURL = `${appProtocol}://${import.meta.env.VITE_APP_API_URL}`;
|
|
||||||
|
if (!import.meta.env.VITE_API_URL) {
|
||||||
|
logger.warn('axios', '⚠️ VITE_API_URL not set, defaulting to http://localhost:8080');
|
||||||
|
}
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|||||||
1
src/components/QueueStatusIndicator.tsx
Normal file
1
src/components/QueueStatusIndicator.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -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 { useNavigate } from 'react-router-dom';
|
||||||
|
import { Session, User } from '@supabase/supabase-js';
|
||||||
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { supabase } from '../supabaseClient';
|
import { supabase } from '../supabaseClient';
|
||||||
|
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
||||||
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
user: CCUser | null;
|
user: CCUser | null;
|
||||||
@ -28,64 +31,205 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [user, setUser] = useState<CCUser | null>(null);
|
const [user, setUser] = useState<CCUser | null>(null);
|
||||||
const [user_role, setUserRole] = useState<string | 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);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const persistSession = useCallback((session: Session | null) => {
|
||||||
const loadUser = async () => {
|
if (session) {
|
||||||
try {
|
storageService.set(StorageKeys.SUPABASE_SESSION, session);
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
|
storageService.remove(StorageKeys.SUPABASE_SESSION);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreSessionFromStorage = useCallback(async (): Promise<Session | null> => {
|
||||||
|
const persistedSession = storageService.get(StorageKeys.SUPABASE_SESSION);
|
||||||
|
|
||||||
|
if (!persistedSession) {
|
||||||
|
return 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);
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
persistSession(null);
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('auth-context', '❌ Failed to load user', { error });
|
logger.error('auth-context', '❌ Failed to load initial session', { error });
|
||||||
|
if (isMounted) {
|
||||||
setError(error instanceof Error ? error : new Error('Failed to load user'));
|
setError(error instanceof Error ? error : new Error('Failed to load user'));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadUser();
|
loadInitialSession();
|
||||||
|
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
if (event === 'SIGNED_IN' && session?.user) {
|
async (event, session) => {
|
||||||
const metadata = session.user.user_metadata as CCUserMetadata;
|
if (!isMounted) {
|
||||||
setUser({
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [buildUserFromSupabase, persistSession, restoreSessionFromStorage]);
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
@ -97,19 +241,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (signInError) throw signInError;
|
if (signInError) throw signInError;
|
||||||
|
|
||||||
|
if (data.session) {
|
||||||
|
persistSession(data.session);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
const metadata = data.user.user_metadata as CCUserMetadata;
|
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
|
||||||
setUser({
|
setUser(resolvedUser);
|
||||||
id: data.user.id,
|
setUserRole(role);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('auth-context', '❌ Sign in failed', { error });
|
logger.error('auth-context', '❌ Sign in failed', { error });
|
||||||
@ -124,6 +263,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
|
persistSession(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -29,6 +29,13 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
logger.debug('neo-institute-context', '🔄 useEffect triggered', {
|
||||||
|
isUserInitialized,
|
||||||
|
hasProfile: !!profile,
|
||||||
|
hasUser: !!user,
|
||||||
|
isInitialized
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for user profile to be ready
|
// Wait for user profile to be ready
|
||||||
if (!isUserInitialized) {
|
if (!isUserInitialized) {
|
||||||
logger.debug('neo-institute-context', '⏳ Waiting for user initialization...');
|
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) {
|
if (!profile || !profile.school_db_name) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
|
logger.debug('neo-institute-context', 'ℹ️ No school database; marking institute context ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +62,7 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
|
|||||||
if (node) {
|
if (node) {
|
||||||
setSchoolNode(node);
|
setSchoolNode(node);
|
||||||
logger.debug('neo-institute-context', '✅ School node loaded', {
|
logger.debug('neo-institute-context', '✅ School node loaded', {
|
||||||
schoolId: node.unique_id,
|
schoolId: node.uuid_string,
|
||||||
dbName: profile.school_db_name
|
dbName: profile.school_db_name
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -71,11 +79,12 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
|
logger.debug('neo-institute-context', '✅ Institute context initialization complete');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSchoolNode();
|
loadSchoolNode();
|
||||||
}, [user?.email, profile, isUserInitialized]);
|
}, [user, profile, isUserInitialized, isInitialized]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NeoInstituteContext.Provider value={{
|
<NeoInstituteContext.Provider value={{
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useAuth } from './AuthContext';
|
|||||||
import { useUser } from './UserContext';
|
import { useUser } from './UserContext';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
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 { CalendarStructure, WorkerStructure } from '../types/navigation';
|
||||||
import { useNavigationStore } from '../stores/navigationStore';
|
import { useNavigationStore } from '../stores/navigationStore';
|
||||||
|
|
||||||
@ -11,7 +12,7 @@ export interface CalendarNode {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
title: string;
|
title: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
type?: CCCalendarNodeProps['__primarylabel__'];
|
type?: CCCalendarNodeProps['__primarylabel__'];
|
||||||
nodeData?: CCCalendarNodeProps;
|
nodeData?: CCCalendarNodeProps;
|
||||||
}
|
}
|
||||||
@ -20,7 +21,7 @@ export interface WorkerNode {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
title: string;
|
title: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
type?: CCUserTeacherTimetableNodeProps['__primarylabel__'];
|
type?: CCUserTeacherTimetableNodeProps['__primarylabel__'];
|
||||||
nodeData?: CCUserTeacherTimetableNodeProps;
|
nodeData?: CCUserTeacherTimetableNodeProps;
|
||||||
}
|
}
|
||||||
@ -162,8 +163,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
isLocked: false,
|
isLocked: false,
|
||||||
__primarylabel__: 'UserTeacherTimetable',
|
__primarylabel__: 'UserTeacherTimetable',
|
||||||
unique_id: '',
|
uuid_string: '',
|
||||||
tldraw_snapshot: '',
|
node_storage_path: '',
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
merged: new Date().toISOString(),
|
merged: new Date().toISOString(),
|
||||||
state: {
|
state: {
|
||||||
@ -177,7 +178,34 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
// Initialize context when dependencies are ready
|
// Initialize context when dependencies are ready
|
||||||
useEffect(() => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,7 +217,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
// Set database names
|
// Set database names
|
||||||
const userDb = profile.user_db_name || (user?.email ?
|
const userDb = profile.user_db_name || (user?.email ?
|
||||||
`cc.users.${user.email.replace('@', 'at').replace(/\./g, 'dot')}` : null);
|
DatabaseNameService.getStoredUserDatabase() || null : null);
|
||||||
|
|
||||||
if (!userDb) {
|
if (!userDb) {
|
||||||
throw new Error('No user database name available');
|
throw new Error('No user database name available');
|
||||||
@ -199,6 +227,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
logger.debug('neo-user-context', '🔄 Starting context initialization');
|
logger.debug('neo-user-context', '🔄 Starting context initialization');
|
||||||
|
|
||||||
// Initialize user node
|
// Initialize user node
|
||||||
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
main: 'profile',
|
main: 'profile',
|
||||||
base: 'profile',
|
base: 'profile',
|
||||||
@ -206,12 +235,12 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
}, userDb, profile.school_db_name);
|
}, userDb, profile.school_db_name);
|
||||||
|
|
||||||
const userNavigationNode = navigationStore.context.node;
|
const userNavigationNode = navigationStore.context.node;
|
||||||
if (userNavigationNode?.data) {
|
if (userNavigationNode?.id && userNavigationNode?.data) {
|
||||||
const userNodeData: CCUserNodeProps = {
|
const userNodeData: CCUserNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'User',
|
__primarylabel__: 'User',
|
||||||
unique_id: userNavigationNode.id,
|
uuid_string: userNavigationNode.id,
|
||||||
tldraw_snapshot: userNavigationNode.tldraw_snapshot || '',
|
node_storage_path: userNavigationNode.node_storage_path || '',
|
||||||
title: String(userNavigationNode.data?.user_name || 'User'),
|
title: String(userNavigationNode.data?.user_name || 'User'),
|
||||||
user_name: String(userNavigationNode.data?.user_name || 'User'),
|
user_name: String(userNavigationNode.data?.user_name || 'User'),
|
||||||
user_email: user?.email || '',
|
user_email: user?.email || '',
|
||||||
@ -220,6 +249,20 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
worker_node_data: JSON.stringify(userNavigationNode.data || {})
|
worker_node_data: JSON.stringify(userNavigationNode.data || {})
|
||||||
};
|
};
|
||||||
setUserNode(userNodeData);
|
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
|
// Set final state
|
||||||
@ -241,7 +284,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
initializeContext();
|
initializeContext();
|
||||||
}, [user?.email, profile, isUserInitialized, navigationStore, isInitialized]);
|
}, [user, profile, isUserInitialized, navigationStore, isInitialized]);
|
||||||
|
|
||||||
// Calendar Navigation Functions
|
// Calendar Navigation Functions
|
||||||
const navigateToDay = async (id: string) => {
|
const navigateToDay = async (id: string) => {
|
||||||
@ -258,8 +301,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCCalendarNodeProps = {
|
const nodeData: CCCalendarNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'CalendarDay',
|
__primarylabel__: 'CalendarDay',
|
||||||
unique_id: id || node.id,
|
uuid_string: id || node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
name: node.label,
|
name: node.label,
|
||||||
calendar_type: 'day',
|
calendar_type: 'day',
|
||||||
@ -272,7 +315,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: id || node.id,
|
id: id || node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'CalendarDay',
|
type: 'CalendarDay',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -298,8 +341,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCCalendarNodeProps = {
|
const nodeData: CCCalendarNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'CalendarWeek',
|
__primarylabel__: 'CalendarWeek',
|
||||||
unique_id: id || node.id,
|
uuid_string: id || node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
name: node.label,
|
name: node.label,
|
||||||
calendar_type: 'week',
|
calendar_type: 'week',
|
||||||
@ -312,7 +355,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: id || node.id,
|
id: id || node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'CalendarWeek',
|
type: 'CalendarWeek',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -338,8 +381,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCCalendarNodeProps = {
|
const nodeData: CCCalendarNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'CalendarMonth',
|
__primarylabel__: 'CalendarMonth',
|
||||||
unique_id: id || node.id,
|
uuid_string: id || node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
name: node.label,
|
name: node.label,
|
||||||
calendar_type: 'month',
|
calendar_type: 'month',
|
||||||
@ -352,7 +395,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: id || node.id,
|
id: id || node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'CalendarMonth',
|
type: 'CalendarMonth',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -378,8 +421,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCCalendarNodeProps = {
|
const nodeData: CCCalendarNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'CalendarYear',
|
__primarylabel__: 'CalendarYear',
|
||||||
unique_id: id || node.id,
|
uuid_string: id || node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
name: node.label,
|
name: node.label,
|
||||||
calendar_type: 'year',
|
calendar_type: 'year',
|
||||||
@ -392,7 +435,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: id || node.id,
|
id: id || node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'CalendarYear',
|
type: 'CalendarYear',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -419,8 +462,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'UserTeacherTimetable',
|
__primarylabel__: 'UserTeacherTimetable',
|
||||||
unique_id: id || node.id,
|
uuid_string: id || node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
school_db_name: workerDbName || '',
|
school_db_name: workerDbName || '',
|
||||||
school_timetable_id: id || node.id
|
school_timetable_id: id || node.id
|
||||||
@ -430,7 +473,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: id || node.id,
|
id: id || node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'UserTeacherTimetable',
|
type: 'UserTeacherTimetable',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -456,8 +499,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'UserTeacherTimetable',
|
__primarylabel__: 'UserTeacherTimetable',
|
||||||
unique_id: id || node.id,
|
uuid_string: id || node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
school_db_name: workerDbName || '',
|
school_db_name: workerDbName || '',
|
||||||
school_timetable_id: id || node.id
|
school_timetable_id: id || node.id
|
||||||
@ -467,7 +510,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: id || node.id,
|
id: id || node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'UserTeacherTimetable',
|
type: 'UserTeacherTimetable',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -493,8 +536,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'UserTeacherTimetable',
|
__primarylabel__: 'UserTeacherTimetable',
|
||||||
unique_id: id || node.id,
|
uuid_string: id || node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
school_db_name: workerDbName || '',
|
school_db_name: workerDbName || '',
|
||||||
school_timetable_id: id || node.id
|
school_timetable_id: id || node.id
|
||||||
@ -504,7 +547,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: id || node.id,
|
id: id || node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'UserTeacherTimetable',
|
type: 'UserTeacherTimetable',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -531,8 +574,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'UserTeacherTimetable',
|
__primarylabel__: 'UserTeacherTimetable',
|
||||||
unique_id: node.id,
|
uuid_string: node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
school_db_name: workerDbName || '',
|
school_db_name: workerDbName || '',
|
||||||
school_timetable_id: node.id
|
school_timetable_id: node.id
|
||||||
@ -542,7 +585,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'UserTeacherTimetable',
|
type: 'UserTeacherTimetable',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
@ -569,8 +612,8 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||||
...getBaseNodeProps(),
|
...getBaseNodeProps(),
|
||||||
__primarylabel__: 'UserTeacherTimetable',
|
__primarylabel__: 'UserTeacherTimetable',
|
||||||
unique_id: node.id,
|
uuid_string: node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
title: node.label,
|
title: node.label,
|
||||||
school_db_name: workerDbName || '',
|
school_db_name: workerDbName || '',
|
||||||
school_timetable_id: node.id
|
school_timetable_id: node.id
|
||||||
@ -580,7 +623,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
label: node.label,
|
label: node.label,
|
||||||
title: node.label,
|
title: node.label,
|
||||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
node_storage_path: node.node_storage_path || '',
|
||||||
type: 'UserTeacherTimetable',
|
type: 'UserTeacherTimetable',
|
||||||
nodeData
|
nodeData
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 { supabase } from '../supabaseClient';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
||||||
import { UserPreferences } from '../services/auth/profileService';
|
import { UserPreferences } from '../services/auth/profileService';
|
||||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
||||||
|
import { provisionUser } from '../services/provisioningService';
|
||||||
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||||
|
|
||||||
export interface UserContextType {
|
export interface UserContextType {
|
||||||
user: CCUser | null;
|
user: CCUser | null;
|
||||||
@ -31,7 +34,7 @@ export const UserContext = createContext<UserContextType>({
|
|||||||
clearError: () => {}
|
clearError: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function UserProvider({ children }: { children: React.ReactNode }) {
|
export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [user] = useState<CCUser | null>(null);
|
const [user] = useState<CCUser | null>(null);
|
||||||
const [profile, setProfile] = useState<CCUser | null>(null);
|
const [profile, setProfile] = useState<CCUser | null>(null);
|
||||||
const [preferences, setPreferences] = useState<UserPreferences>({});
|
const [preferences, setPreferences] = useState<UserPreferences>({});
|
||||||
@ -39,46 +42,258 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const [isMobile] = useState(window.innerWidth <= 768);
|
const [isMobile] = useState(window.innerWidth <= 768);
|
||||||
|
const mountedRef = React.useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
// Use the main Supabase client for all operations to ensure proper session persistence
|
||||||
const loadUserProfile = async () => {
|
// This avoids the "Multiple GoTrueClient instances" warning and ensures session restoration works
|
||||||
try {
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
const resolveProfile = useCallback(async (supabaseUser?: User | null, session?: Session | null) => {
|
||||||
setProfile(null);
|
// Prevent duplicate work when we already have the same user resolved
|
||||||
setLoading(false);
|
if (mountedRef.current && isInitialized) {
|
||||||
setIsInitialized(true);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
if (incomingUserId && resolvedUserId && resolvedUserId === incomingUserId) {
|
||||||
.from('profiles')
|
logger.debug('user-context', '⚠️ Profile already initialized for current user, skipping resolution');
|
||||||
.select('*')
|
return;
|
||||||
.eq('id', user.id)
|
}
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = user.user_metadata as CCUserMetadata;
|
let userInfo: User | null = null; // Declare at function scope
|
||||||
const userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', metadata.username || '');
|
try {
|
||||||
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('user-context', '🔧 Step 4: User info available, proceeding...', {
|
||||||
|
userId: userInfo.id,
|
||||||
|
email: userInfo.email
|
||||||
|
});
|
||||||
|
|
||||||
|
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' } };
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('user-context', '🔧 Step 5b2: Direct fetch completed...', {
|
||||||
|
userId: userInfo.id,
|
||||||
|
hasData: !!data,
|
||||||
|
hasError: !!error
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = {
|
const userProfile: CCUser = {
|
||||||
id: user.id,
|
id: userInfo.id,
|
||||||
email: user.email,
|
email: userInfo.email,
|
||||||
user_type: metadata.user_type || '',
|
user_type: metadata.user_type || '',
|
||||||
username: metadata.username || '',
|
username: metadata.username || '',
|
||||||
display_name: metadata.display_name || '',
|
display_name: String(metadata.display_name || ''),
|
||||||
user_db_name: userDbName,
|
user_db_name: String(userDbName || ''),
|
||||||
school_db_name: schoolDbName,
|
school_db_name: String(schoolDbName || ''),
|
||||||
created_at: user.created_at,
|
created_at: userInfo.created_at,
|
||||||
updated_at: user.updated_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);
|
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', {
|
logger.debug('user-context', '✅ User profile loaded', {
|
||||||
userId: userProfile.id,
|
userId: userProfile.id,
|
||||||
@ -87,24 +302,110 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
|
|||||||
userDbName: userProfile.user_db_name,
|
userDbName: userProfile.user_db_name,
|
||||||
schoolDbName: userProfile.school_db_name
|
schoolDbName: userProfile.school_db_name
|
||||||
});
|
});
|
||||||
|
setError(null);
|
||||||
// Load preferences from profile data
|
|
||||||
setPreferences({
|
|
||||||
theme: data.theme || 'system',
|
|
||||||
notifications: data.notifications_enabled || false
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('user-context', '❌ Failed to load user profile', { error });
|
logger.error('user-context', '❌ Failed to load user profile', { error });
|
||||||
setError(error instanceof Error ? error : new Error('Failed to load user profile'));
|
if (!mountedRef.current) {
|
||||||
} finally {
|
return;
|
||||||
setLoading(false);
|
|
||||||
setIsInitialized(true);
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
};
|
};
|
||||||
|
|
||||||
loadUserProfile();
|
DatabaseNameService.rememberDatabaseNames({
|
||||||
}, []);
|
userDbName: fallbackProfile.user_db_name,
|
||||||
|
schoolDbName: fallbackProfile.school_db_name
|
||||||
|
});
|
||||||
|
|
||||||
|
setProfile(fallbackProfile);
|
||||||
|
logger.debug('user-context', '✅ Fallback profile created', {
|
||||||
|
userId: fallbackProfile.id,
|
||||||
|
userType: fallbackProfile.user_type,
|
||||||
|
userDbName: fallbackProfile.user_db_name
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setProfile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) => {
|
const updateProfile = async (updates: Partial<CCUser>) => {
|
||||||
if (!user?.id || !profile) {
|
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 (
|
return (
|
||||||
<UserContext.Provider
|
<UserContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export type LogCategory =
|
|||||||
| 'auth-service'
|
| 'auth-service'
|
||||||
| 'graph-service'
|
| 'graph-service'
|
||||||
| 'registration-service'
|
| 'registration-service'
|
||||||
|
| 'provisioning-service'
|
||||||
| 'snapshot-service'
|
| 'snapshot-service'
|
||||||
| 'shared-store-service'
|
| 'shared-store-service'
|
||||||
| 'sync-service'
|
| 'sync-service'
|
||||||
@ -281,6 +282,7 @@ logger.setConfig({
|
|||||||
'single-player-page',
|
'single-player-page',
|
||||||
'user-toolbar',
|
'user-toolbar',
|
||||||
'registration-service',
|
'registration-service',
|
||||||
|
'provisioning-service',
|
||||||
'graph-service',
|
'graph-service',
|
||||||
'graph-shape',
|
'graph-shape',
|
||||||
'calendar-shape',
|
'calendar-shape',
|
||||||
|
|||||||
@ -28,7 +28,8 @@ import {
|
|||||||
AssignmentTurnedIn as ExamMarkerIcon,
|
AssignmentTurnedIn as ExamMarkerIcon,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
AdminPanelSettings as AdminIcon
|
AdminPanelSettings as AdminIcon,
|
||||||
|
Home as HomeIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { HEADER_HEIGHT } from './Layout';
|
import { HEADER_HEIGHT } from './Layout';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
@ -125,7 +126,7 @@ const Header: React.FC = () => {
|
|||||||
},
|
},
|
||||||
fontSize: { xs: '1rem', sm: '1.25rem' }
|
fontSize: { xs: '1rem', sm: '1.25rem' }
|
||||||
}}
|
}}
|
||||||
onClick={() => navigate(isAuthenticated ? '/single-player' : '/')}
|
onClick={() => navigate(isAuthenticated ? '/dashboard' : '/')}
|
||||||
>
|
>
|
||||||
ClassroomCopilot
|
ClassroomCopilot
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -192,6 +193,13 @@ const Header: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isAuthenticated ? [
|
{isAuthenticated ? [
|
||||||
|
<MenuItem key="dashboard" onClick={() => handleNavigation('/dashboard')}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<HomeIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Dashboard" />
|
||||||
|
</MenuItem>,
|
||||||
|
<Divider key="dashboard-divider" />,
|
||||||
// Development Tools Section
|
// Development Tools Section
|
||||||
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
|
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
|
|||||||
@ -37,8 +37,8 @@ export default function AdminDashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleReturn = () => {
|
const handleReturn = () => {
|
||||||
logger.info('admin-page', '🏠 Returning to single player page');
|
logger.info('admin-page', '🏠 Returning to dashboard');
|
||||||
navigate('/single-player');
|
navigate('/dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isSuperAdmin) {
|
if (!isSuperAdmin) {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
navigate('/single-player');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, navigate]);
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ const LoginPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signIn(credentials.email, credentials.password);
|
await signIn(credentials.email, credentials.password);
|
||||||
navigate('/single-player');
|
navigate('/dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('login-page', '❌ Login failed', error);
|
logger.error('login-page', '❌ Login failed', error);
|
||||||
setError(error instanceof Error ? error.message : 'Login failed');
|
setError(error instanceof Error ? error.message : 'Login failed');
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const SignupPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
navigate('/single-player');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, navigate]);
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ const SignupPage: React.FC = () => {
|
|||||||
displayName
|
displayName
|
||||||
);
|
);
|
||||||
if (result.user) {
|
if (result.user) {
|
||||||
navigate('/single-player');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('signup-page', '❌ Registration failed', error);
|
logger.error('signup-page', '❌ Registration failed', error);
|
||||||
@ -117,4 +117,3 @@ const SignupPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default SignupPage;
|
export default SignupPage;
|
||||||
|
|
||||||
|
|||||||
876
src/pages/dev/SimpleUploadTest.tsx
Normal file
876
src/pages/dev/SimpleUploadTest.tsx
Normal 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;
|
||||||
471
src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx
Normal file
471
src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx
Normal 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  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;
|
||||||
|
|
||||||
|
|
||||||
403
src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx
Normal file
403
src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx
Normal 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)' }} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -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;
|
||||||
|
|
||||||
570
src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx
Normal file
570
src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx
Normal 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;
|
||||||
363
src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx
Normal file
363
src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
@ -123,37 +123,73 @@ export default function SinglePlayerPage() {
|
|||||||
logger.debug('single-player-page', '✅ TLStore created');
|
logger.debug('single-player-page', '✅ TLStore created');
|
||||||
|
|
||||||
// 2. Initialize snapshot service
|
// 2. Initialize snapshot service
|
||||||
const snapshotService = new NavigationSnapshotService(newStore);
|
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
||||||
snapshotServiceRef.current = snapshotService;
|
snapshotServiceRef.current = snapshotService;
|
||||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
||||||
|
|
||||||
// 3. Load initial snapshot if we have a node
|
// 3. Load initial snapshot if we have a node
|
||||||
if (context.node) {
|
if (context.node) {
|
||||||
|
const nodeStoragePath = getNodeStoragePath(context.node);
|
||||||
|
if (nodeStoragePath) {
|
||||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
||||||
dbName: user.user_db_name,
|
dbName: user.user_db_name,
|
||||||
tldraw_snapshot: context.node.tldraw_snapshot,
|
node: context.node,
|
||||||
|
node_storage_path: nodeStoragePath,
|
||||||
user_type: user.user_type,
|
user_type: user.user_type,
|
||||||
username: user.username
|
username: user.username
|
||||||
});
|
});
|
||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
context.node.tldraw_snapshot,
|
nodeStoragePath,
|
||||||
user.user_db_name,
|
user.user_db_name,
|
||||||
newStore,
|
newStore,
|
||||||
setLoadingState
|
setLoadingState,
|
||||||
|
undefined, // sharedStore
|
||||||
|
editorRef.current || undefined // editor
|
||||||
);
|
);
|
||||||
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
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 {
|
} else {
|
||||||
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
|
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(() => {
|
newStore.listen(() => {
|
||||||
if (snapshotServiceRef.current && context.node) {
|
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) {
|
||||||
logger.debug('single-player-page', '💾 Auto-saving changes');
|
// Skip if already saving
|
||||||
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
|
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);
|
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();
|
initializeStoreAndSnapshot();
|
||||||
}, [isEditorReady, user, context.node, editorRef.current]);
|
}, [isEditorReady, user, context.node]);
|
||||||
|
|
||||||
// Handle initial node placement
|
// Handle initial node placement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const placeInitialNode = async () => {
|
const placeInitialNode = async () => {
|
||||||
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,7 +298,7 @@ export default function SinglePlayerPage() {
|
|||||||
setLoadingState({ status: 'loading', error: '' });
|
setLoadingState({ status: 'loading', error: '' });
|
||||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
logger.debug('single-player-page', '🔄 Loading node data', {
|
||||||
nodeId: currentNode.id,
|
nodeId: currentNode.id,
|
||||||
tldraw_snapshot: currentNode.tldraw_snapshot,
|
node_storage_path: currentNode.node_storage_path,
|
||||||
isInitialLoad
|
isInitialLoad
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -258,7 +325,7 @@ export default function SinglePlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleNodeChange();
|
handleNodeChange();
|
||||||
}, [context.node?.id, context.history, store]);
|
}, [context.node, context.history, store, isInitialLoad]);
|
||||||
|
|
||||||
// Initialize preferences when user is available
|
// Initialize preferences when user is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -270,7 +337,7 @@ export default function SinglePlayerPage() {
|
|||||||
|
|
||||||
// Redirect if no user or incorrect role
|
// Redirect if no user or incorrect role
|
||||||
useEffect(() => {
|
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', {
|
logger.info('single-player-page', '🚪 Redirecting to home - no user or incorrect role', {
|
||||||
hasUser: !!user,
|
hasUser: !!user,
|
||||||
userType: user?.user_type
|
userType: user?.user_type
|
||||||
@ -467,6 +534,11 @@ export default function SinglePlayerPage() {
|
|||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
logger.debug('single-player-page', '✅ Editor ref set');
|
logger.debug('single-player-page', '✅ Editor ref set');
|
||||||
|
|
||||||
|
// Update snapshot service with editor reference
|
||||||
|
if (snapshotServiceRef.current) {
|
||||||
|
snapshotServiceRef.current.setEditor(editor);
|
||||||
|
}
|
||||||
|
|
||||||
setIsEditorReady(true);
|
setIsEditorReady(true);
|
||||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||||
editorId: editor.store.id,
|
editorId: editor.store.id,
|
||||||
@ -482,9 +554,57 @@ export default function SinglePlayerPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> => {
|
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
|
// 1. Always fetch fresh data
|
||||||
const dbName = UserNeoDBService.getNodeDatabaseName(node);
|
// 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);
|
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
|
||||||
|
|
||||||
if (!fetchedData?.node_data) {
|
if (!fetchedData?.node_data) {
|
||||||
@ -495,7 +615,7 @@ const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
|||||||
const theme = getThemeFromLabel(node.type);
|
const theme = getThemeFromLabel(node.type);
|
||||||
return {
|
return {
|
||||||
...fetchedData.node_data,
|
...fetchedData.node_data,
|
||||||
title: fetchedData.node_data.title || node.label,
|
title: String(fetchedData.node_data.title || node.label || ''),
|
||||||
w: 500,
|
w: 500,
|
||||||
h: 350,
|
h: 350,
|
||||||
state: {
|
state: {
|
||||||
@ -508,7 +628,15 @@ const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
|||||||
backgroundColor: theme.backgroundColor,
|
backgroundColor: theme.backgroundColor,
|
||||||
isLocked: false,
|
isLocked: false,
|
||||||
__primarylabel__: node.type,
|
__primarylabel__: node.type,
|
||||||
unique_id: node.id,
|
uuid_string: node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,7 +22,7 @@ interface Event {
|
|||||||
subjectClass: string;
|
subjectClass: string;
|
||||||
color: string;
|
color: string;
|
||||||
periodCode: string;
|
periodCode: string;
|
||||||
tldraw_snapshot?: string;
|
node_storage_path?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,12 +155,12 @@ const CalendarPage: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug('calendar', 'Fetching events', {
|
logger.debug('calendar', 'Fetching events', {
|
||||||
unique_id: workerNode.nodeData.unique_id,
|
uuid_string: workerNode.nodeData.uuid_string,
|
||||||
school_db_name: workerDbName
|
school_db_name: workerDbName
|
||||||
});
|
});
|
||||||
|
|
||||||
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
|
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
|
||||||
workerNode.nodeData.unique_id,
|
workerNode.nodeData.uuid_string,
|
||||||
workerDbName || ''
|
workerDbName || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -168,7 +168,7 @@ const CalendarPage: React.FC = () => {
|
|||||||
...event,
|
...event,
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
...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]);
|
}, [fetchEvents]);
|
||||||
|
|
||||||
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
|
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
|
||||||
const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot;
|
const node_storage_path = clickInfo.event.extendedProps?.node_storage_path;
|
||||||
if (tldraw_snapshot) {
|
if (node_storage_path) {
|
||||||
// TODO: Implement tldraw_snapshot retrieval from storage API
|
// TODO: Implement node_storage_path retrieval from storage API
|
||||||
// For now, we'll just log it
|
// For now, we'll just log it
|
||||||
console.log('TLDraw snapshot:', tldraw_snapshot);
|
console.log('TLDraw snapshot:', node_storage_path);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
96
src/pages/user/dashboardPage.tsx
Normal file
96
src/pages/user/dashboardPage.tsx
Normal 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;
|
||||||
@ -44,11 +44,19 @@ export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
|
|||||||
// Default to student if no user type specified
|
// Default to student if no user type specified
|
||||||
const userType = metadata.user_type || 'student';
|
const userType = metadata.user_type || 'student';
|
||||||
|
|
||||||
const userDbName = DatabaseNameService.getUserPrivateDB(
|
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
|
||||||
|
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
||||||
|
|
||||||
|
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(
|
||||||
userType,
|
userType,
|
||||||
username
|
user.id
|
||||||
);
|
);
|
||||||
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
|
const schoolDbName = metadata.school_db_name || metadata.worker_db_name || storedSchoolDb || '';
|
||||||
|
|
||||||
|
DatabaseNameService.rememberDatabaseNames({
|
||||||
|
userDbName,
|
||||||
|
schoolDbName
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@ -222,6 +230,7 @@ class AuthService {
|
|||||||
storageService.set(StorageKeys.USER_ROLE, ccUser.user_type);
|
storageService.set(StorageKeys.USER_ROLE, ccUser.user_type);
|
||||||
storageService.set(StorageKeys.USER, ccUser);
|
storageService.set(StorageKeys.USER, ccUser);
|
||||||
storageService.set(StorageKeys.SUPABASE_TOKEN, data.session.access_token);
|
storageService.set(StorageKeys.SUPABASE_TOKEN, data.session.access_token);
|
||||||
|
storageService.set(StorageKeys.SUPABASE_SESSION, data.session);
|
||||||
|
|
||||||
logger.info('auth-service', '✅ Login successful', {
|
logger.info('auth-service', '✅ Login successful', {
|
||||||
userId: ccUser.id,
|
userId: ccUser.id,
|
||||||
@ -262,4 +271,3 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authService = AuthService.getInstance();
|
export const authService = AuthService.getInstance();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TLUserPreferences, TLUser } from '@tldraw/tldraw';
|
import { TLUserPreferences, TLUser } from '@tldraw/tldraw';
|
||||||
|
import { Session } from '@supabase/supabase-js';
|
||||||
import { CCUser } from '../../services/auth/authService';
|
import { CCUser } from '../../services/auth/authService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
@ -8,6 +9,7 @@ export enum StorageKeys {
|
|||||||
USER = 'user',
|
USER = 'user',
|
||||||
USER_ROLE = 'user_role',
|
USER_ROLE = 'user_role',
|
||||||
SUPABASE_TOKEN = 'supabase_token',
|
SUPABASE_TOKEN = 'supabase_token',
|
||||||
|
SUPABASE_SESSION = 'supabase_session',
|
||||||
MS_TOKEN = 'msAccessToken',
|
MS_TOKEN = 'msAccessToken',
|
||||||
NEO4J_USER_DB = 'neo4jUserDbName',
|
NEO4J_USER_DB = 'neo4jUserDbName',
|
||||||
NEO4J_WORKER_DB = 'neo4jWorkerDbName',
|
NEO4J_WORKER_DB = 'neo4jWorkerDbName',
|
||||||
@ -27,6 +29,7 @@ interface StorageValueTypes {
|
|||||||
[StorageKeys.USER]: CCUser;
|
[StorageKeys.USER]: CCUser;
|
||||||
[StorageKeys.USER_ROLE]: string;
|
[StorageKeys.USER_ROLE]: string;
|
||||||
[StorageKeys.SUPABASE_TOKEN]: string;
|
[StorageKeys.SUPABASE_TOKEN]: string;
|
||||||
|
[StorageKeys.SUPABASE_SESSION]: Session;
|
||||||
[StorageKeys.MS_TOKEN]: string;
|
[StorageKeys.MS_TOKEN]: string;
|
||||||
[StorageKeys.NEO4J_USER_DB]: string;
|
[StorageKeys.NEO4J_USER_DB]: string;
|
||||||
[StorageKeys.NEO4J_WORKER_DB]: string;
|
[StorageKeys.NEO4J_WORKER_DB]: string;
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { CCUser, convertToCCUser } from '../../services/auth/authService';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { formatEmailForDatabase } from '../graph/neoDBService';
|
import { formatEmailForDatabase } from '../graph/neoDBService';
|
||||||
import { RegistrationResponse } from '../../services/auth/authService';
|
import { RegistrationResponse } from '../../services/auth/authService';
|
||||||
import { neoRegistrationService } from '../graph/neoRegistrationService';
|
|
||||||
import { storageService, StorageKeys } from './localStorageService';
|
import { storageService, StorageKeys } from './localStorageService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
|
import { provisionUser } from '../provisioningService';
|
||||||
|
import { DatabaseNameService } from '../graph/databaseNameService';
|
||||||
|
|
||||||
const REGISTRATION_SERVICE = 'registration-service';
|
const REGISTRATION_SERVICE = 'registration-service';
|
||||||
|
|
||||||
@ -76,17 +77,42 @@ export class RegistrationService {
|
|||||||
|
|
||||||
storageService.set(StorageKeys.IS_NEW_REGISTRATION, true);
|
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
|
// 3. Create Neo4j nodes
|
||||||
try {
|
try {
|
||||||
const userNode = await neoRegistrationService.registerNeo4JUser(
|
const provisioned = await provisionUser(ccUser.id, provisioningToken);
|
||||||
ccUser,
|
if (provisioned) {
|
||||||
username, // Pass username for database operations
|
ccUser.user_db_name = provisioned.user_db_name;
|
||||||
credentials.role
|
if (provisioned.worker_db_name) {
|
||||||
);
|
ccUser.school_db_name = provisioned.worker_db_name;
|
||||||
|
}
|
||||||
logger.info(REGISTRATION_SERVICE, '✅ Registration successful with Neo4j setup', {
|
DatabaseNameService.rememberDatabaseNames({
|
||||||
|
userDbName: ccUser.user_db_name,
|
||||||
|
schoolDbName: ccUser.school_db_name
|
||||||
|
});
|
||||||
|
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
|
||||||
userId: ccUser.id,
|
userId: ccUser.id,
|
||||||
hasUserNode: !!userNode
|
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,
|
||||||
|
error: provisionError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseNameService.rememberDatabaseNames({
|
||||||
|
userDbName: ccUser.user_db_name,
|
||||||
|
schoolDbName: ccUser.school_db_name
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -95,19 +121,6 @@ export class RegistrationService {
|
|||||||
userRole: credentials.role,
|
userRole: credentials.role,
|
||||||
message: 'Registration successful'
|
message: 'Registration successful'
|
||||||
};
|
};
|
||||||
} catch (neo4jError) {
|
|
||||||
logger.warn(REGISTRATION_SERVICE, '⚠️ Neo4j setup problem', {
|
|
||||||
userId: ccUser.id,
|
|
||||||
error: neo4jError
|
|
||||||
});
|
|
||||||
// Return success even if Neo4j setup is pending
|
|
||||||
return {
|
|
||||||
user: ccUser,
|
|
||||||
accessToken: authData.session?.access_token || null,
|
|
||||||
userRole: credentials.role,
|
|
||||||
message: 'Registration successful - Neo4j setup pending'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(REGISTRATION_SERVICE, '❌ Registration failed:', error);
|
logger.error(REGISTRATION_SERVICE, '❌ Registration failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -1,14 +1,35 @@
|
|||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
|
import { storageService, StorageKeys } from '../auth/localStorageService';
|
||||||
|
|
||||||
export class DatabaseNameService {
|
export class DatabaseNameService {
|
||||||
static readonly CC_USERS = 'cc.users';
|
static readonly CC_USERS = 'cc.users';
|
||||||
static readonly CC_SCHOOLS = 'cc.institutes';
|
static readonly CC_SCHOOLS = 'cc.institutes';
|
||||||
|
|
||||||
static getUserPrivateDB(userType: string, username: string): string {
|
private static remember(key: StorageKeys, value?: string | null) {
|
||||||
const dbName = `${this.CC_USERS}.${userType}.${username}`;
|
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', {
|
logger.debug('database-name-service', '📥 Generating user private DB name', {
|
||||||
userType,
|
userType,
|
||||||
username,
|
identifier,
|
||||||
dbName
|
dbName
|
||||||
});
|
});
|
||||||
return dbName;
|
return dbName;
|
||||||
@ -24,18 +45,46 @@ export class DatabaseNameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getDevelopmentSchoolDB(): string {
|
static getDevelopmentSchoolDB(): string {
|
||||||
const dbName = `${this.CC_SCHOOLS}.development.default`;
|
const stored = this.recall(StorageKeys.NEO4J_WORKER_DB);
|
||||||
logger.debug('database-name-service', '📥 Getting default school DB name', {
|
if (stored && stored !== `${this.CC_SCHOOLS}.development.default`) {
|
||||||
dbName
|
logger.debug('database-name-service', '📥 Using stored school DB name', {
|
||||||
|
dbName: stored
|
||||||
});
|
});
|
||||||
return dbName;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getContextDatabase(context: string, userType: string, username: string): string {
|
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 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', {
|
logger.debug('database-name-service', '📥 Resolving context database', {
|
||||||
context,
|
context,
|
||||||
userType,
|
userType,
|
||||||
username
|
identifier
|
||||||
});
|
});
|
||||||
|
|
||||||
// For school-related contexts, use the schools database
|
// For school-related contexts, use the schools database
|
||||||
@ -48,7 +97,7 @@ export class DatabaseNameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For user-specific contexts, use their private database
|
// 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', {
|
logger.debug('database-name-service', '✅ Using user private database for context', {
|
||||||
context,
|
context,
|
||||||
dbName: userDb
|
dbName: userDb
|
||||||
|
|||||||
@ -8,20 +8,20 @@ import { logger } from '../../debugConfig';
|
|||||||
|
|
||||||
export class GraphNeoDBService {
|
export class GraphNeoDBService {
|
||||||
static async fetchConnectedNodesAndEdges(
|
static async fetchConnectedNodesAndEdges(
|
||||||
unique_id: string,
|
uuid_string: string,
|
||||||
db_name: string,
|
db_name: string,
|
||||||
editor: Editor
|
editor: Editor
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
logger.debug('graph-service', '📤 Fetching connected nodes', {
|
logger.debug('graph-service', '📤 Fetching connected nodes', {
|
||||||
unique_id,
|
uuid_string,
|
||||||
db_name
|
db_name
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios.get<ConnectedNodesResponse>(
|
const response = await axios.get<ConnectedNodesResponse>(
|
||||||
'/database/tools/get-connected-nodes-and-edges', {
|
'/database/tools/get-connected-nodes-and-edges', {
|
||||||
params: {
|
params: {
|
||||||
unique_id,
|
uuid_string,
|
||||||
db_name
|
db_name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,8 +61,8 @@ export class GraphNeoDBService {
|
|||||||
if (isValidNodeType(connectedNode.type)) {
|
if (isValidNodeType(connectedNode.type)) {
|
||||||
// Convert the simplified node structure to node_data format
|
// Convert the simplified node structure to node_data format
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
unique_id: connectedNode.id,
|
uuid_string: connectedNode.id,
|
||||||
tldraw_snapshot: connectedNode.tldraw_snapshot,
|
node_storage_path: connectedNode.node_storage_path,
|
||||||
name: connectedNode.label,
|
name: connectedNode.label,
|
||||||
__primarylabel__: connectedNode.type as keyof CCNodeTypes,
|
__primarylabel__: connectedNode.type as keyof CCNodeTypes,
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
@ -82,7 +82,7 @@ export class GraphNeoDBService {
|
|||||||
for (const nodeData of nodesToProcess) {
|
for (const nodeData of nodesToProcess) {
|
||||||
await this.createOrUpdateNode(nodeData);
|
await this.createOrUpdateNode(nodeData);
|
||||||
logger.debug('graph-service', '📝 Processed node', {
|
logger.debug('graph-service', '📝 Processed node', {
|
||||||
nodeId: nodeData.unique_id,
|
nodeId: nodeData.uuid_string,
|
||||||
nodeType: nodeData.__primarylabel__
|
nodeType: nodeData.__primarylabel__
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -105,7 +105,7 @@ export class GraphNeoDBService {
|
|||||||
private static async createOrUpdateNode(
|
private static async createOrUpdateNode(
|
||||||
nodeData: NodeResponse['node_data']
|
nodeData: NodeResponse['node_data']
|
||||||
) {
|
) {
|
||||||
const uniqueId = nodeData.unique_id;
|
const uniqueId = nodeData.uuid_string;
|
||||||
const nodeType = nodeData.__primarylabel__;
|
const nodeType = nodeData.__primarylabel__;
|
||||||
|
|
||||||
if (!isValidNodeType(nodeType)) {
|
if (!isValidNodeType(nodeType)) {
|
||||||
@ -126,6 +126,27 @@ export class GraphNeoDBService {
|
|||||||
const defaultProps = shapeUtil.prototype.getDefaultProps();
|
const defaultProps = shapeUtil.prototype.getDefaultProps();
|
||||||
|
|
||||||
// Create the shape with proper typing based on the node type
|
// 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 = {
|
const shape = {
|
||||||
id: createShapeId(uniqueId),
|
id: createShapeId(uniqueId),
|
||||||
type: shapeType,
|
type: shapeType,
|
||||||
@ -137,13 +158,7 @@ export class GraphNeoDBService {
|
|||||||
isLocked: false,
|
isLocked: false,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
meta: {},
|
meta: {},
|
||||||
props: {
|
props: mappedProps
|
||||||
...defaultProps,
|
|
||||||
...nodeData,
|
|
||||||
__primarylabel__: nodeData.__primarylabel__,
|
|
||||||
unique_id: nodeData.unique_id,
|
|
||||||
tldraw_snapshot: nodeData.path as string || '',
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to graphState
|
// Add to graphState
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types'
|
|||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
export interface BaseNodeData {
|
export interface BaseNodeData {
|
||||||
unique_id: string;
|
uuid_string: string;
|
||||||
path: string;
|
path: string;
|
||||||
__primarylabel__: string;
|
__primarylabel__: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@ -46,10 +46,10 @@ class NeoRegistrationService {
|
|||||||
|
|
||||||
// Add school data if we have a school node
|
// Add school data if we have a school node
|
||||||
if (schoolNode) {
|
if (schoolNode) {
|
||||||
formData.append('school_uuid', schoolNode.school_uuid);
|
formData.append('school_uuid_string', schoolNode.uuid_string);
|
||||||
formData.append('school_name', schoolNode.school_name);
|
formData.append('school_name', schoolNode.name);
|
||||||
formData.append('school_website', schoolNode.school_website);
|
formData.append('school_website', schoolNode.website);
|
||||||
formData.append('school_tldraw_snapshot', schoolNode.tldraw_snapshot);
|
formData.append('school_node_storage_path', schoolNode.node_storage_path);
|
||||||
|
|
||||||
// Add worker data based on role
|
// Add worker data based on role
|
||||||
const workerData = role.includes('teacher') ? {
|
const workerData = role.includes('teacher') ? {
|
||||||
@ -72,8 +72,8 @@ class NeoRegistrationService {
|
|||||||
userName: username,
|
userName: username,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
schoolNode: schoolNode ? {
|
schoolNode: schoolNode ? {
|
||||||
uuid: schoolNode.school_uuid,
|
uuid_string: schoolNode.uuid_string,
|
||||||
name: schoolNode.school_name
|
name: schoolNode.name
|
||||||
} : null
|
} : null
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ class NeoRegistrationService {
|
|||||||
|
|
||||||
logger.info('neo4j-service', '✅ Neo4j user registration successful', {
|
logger.info('neo4j-service', '✅ Neo4j user registration successful', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
nodeId: userNode.unique_id,
|
nodeId: userNode.uuid_string,
|
||||||
hasCalendar: !!response.data.data.calendar_nodes
|
hasCalendar: !!response.data.data.calendar_nodes
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -133,11 +133,11 @@ class NeoRegistrationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchSchoolNode(schoolUuid: string): Promise<CCSchoolNodeProps> {
|
async fetchSchoolNode(schoolUrn: string): Promise<CCSchoolNodeProps> {
|
||||||
logger.debug('neo4j-service', '🔄 Fetching school node', { schoolUuid });
|
logger.debug('neo4j-service', '🔄 Fetching school node', { schoolUrn });
|
||||||
|
|
||||||
try {
|
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) {
|
if (response.data?.status === 'success' && response.data.school_node) {
|
||||||
logger.info('neo4j-service', '✅ School node fetched successfully');
|
logger.info('neo4j-service', '✅ School node fetched successfully');
|
||||||
|
|||||||
@ -33,9 +33,10 @@ export class NeoShapeService {
|
|||||||
const width = 500;
|
const width = 500;
|
||||||
const height = 350;
|
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 = {
|
const processedProps = {
|
||||||
...this.processDateTimeFields(nodeData),
|
...this.processDateTimeFields(filteredNodeData),
|
||||||
title: nodeData.title || node.label,
|
title: nodeData.title || node.label,
|
||||||
w: width,
|
w: width,
|
||||||
h: height,
|
h: height,
|
||||||
@ -49,10 +50,19 @@ export class NeoShapeService {
|
|||||||
backgroundColor: theme.backgroundColor,
|
backgroundColor: theme.backgroundColor,
|
||||||
isLocked: false,
|
isLocked: false,
|
||||||
__primarylabel__: node.type,
|
__primarylabel__: node.type,
|
||||||
unique_id: node.id,
|
uuid_string: node.id,
|
||||||
tldraw_snapshot: node.tldraw_snapshot
|
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', {
|
logger.debug('neo-shape-service', '📄 Created shape configuration', {
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
shapeType,
|
shapeType,
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export interface TeacherTimetableEvent {
|
|||||||
subjectClass: string;
|
subjectClass: string;
|
||||||
color: string;
|
color: string;
|
||||||
periodCode: string;
|
periodCode: string;
|
||||||
tldraw_snapshot?: string;
|
node_storage_path?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,21 +42,21 @@ export class TimetableNeoDBService {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('user_node', JSON.stringify({
|
formData.append('user_node', JSON.stringify({
|
||||||
unique_id: userNode.unique_id,
|
uuid_string: userNode.uuid_string,
|
||||||
user_id: userNode.user_id,
|
user_id: userNode.user_id,
|
||||||
user_type: userNode.user_type,
|
user_type: userNode.user_type,
|
||||||
user_name: userNode.user_name,
|
user_name: userNode.user_name,
|
||||||
user_email: userNode.user_email,
|
user_email: userNode.user_email,
|
||||||
tldraw_snapshot: userNode.tldraw_snapshot,
|
node_storage_path: userNode.node_storage_path,
|
||||||
worker_node_data: userNode.worker_node_data
|
worker_node_data: userNode.worker_node_data
|
||||||
|
|
||||||
}));
|
}));
|
||||||
formData.append('worker_node', JSON.stringify({
|
formData.append('worker_node', JSON.stringify({
|
||||||
unique_id: workerNode.unique_id,
|
uuid_string: workerNode.uuid_string,
|
||||||
teacher_code: workerNode.teacher_code,
|
teacher_code: workerNode.teacher_code,
|
||||||
teacher_name_formal: workerNode.teacher_name_formal,
|
teacher_name_formal: workerNode.teacher_name_formal,
|
||||||
teacher_email: workerNode.teacher_email,
|
teacher_email: workerNode.teacher_email,
|
||||||
tldraw_snapshot: workerNode.tldraw_snapshot,
|
node_storage_path: workerNode.node_storage_path,
|
||||||
worker_db_name: workerNode.school_db_name,
|
worker_db_name: workerNode.school_db_name,
|
||||||
user_db_name: workerNode.user_db_name
|
user_db_name: workerNode.user_db_name
|
||||||
}));
|
}));
|
||||||
@ -92,18 +92,18 @@ export class TimetableNeoDBService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async fetchTeacherTimetableEvents(
|
static async fetchTeacherTimetableEvents(
|
||||||
unique_id: string,
|
uuid_string: string,
|
||||||
school_db_name: string
|
school_db_name: string
|
||||||
): Promise<TeacherTimetableEvent[]> {
|
): Promise<TeacherTimetableEvent[]> {
|
||||||
try {
|
try {
|
||||||
logger.debug('timetable-service', '📤 Fetching timetable events', {
|
logger.debug('timetable-service', '📤 Fetching timetable events', {
|
||||||
unique_id,
|
uuid_string,
|
||||||
school_db_name
|
school_db_name
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios.get('/calendar/get_teacher_timetable_events', {
|
const response = await axios.get('/calendar/get_teacher_timetable_events', {
|
||||||
params: {
|
params: {
|
||||||
unique_id,
|
uuid_string,
|
||||||
school_db_name
|
school_db_name
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -216,8 +216,8 @@ export class TimetableNeoDBService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate worker node has required fields
|
// Validate worker node has required fields
|
||||||
const requiredWorkerFields = ['unique_id', 'teacher_code', 'teacher_name_formal', 'teacher_email', 'worker_db_name', 'path'];
|
const requiredWorkerFields = ['uuid_string', '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 requiredUserFields = ['uuid_string', 'user_id', 'user_type', 'user_name', 'user_email', 'path', 'worker_node_data'];
|
||||||
const missingWorkerFields = requiredWorkerFields.filter(field => !(field in workerNode));
|
const missingWorkerFields = requiredWorkerFields.filter(field => !(field in workerNode));
|
||||||
const missingUserFields = requiredUserFields.filter(field => !(field in userNode));
|
const missingUserFields = requiredUserFields.filter(field => !(field in userNode));
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,11 @@ import { useNavigationStore } from '../../stores/navigationStore';
|
|||||||
import { DatabaseNameService } from './databaseNameService';
|
import { DatabaseNameService } from './databaseNameService';
|
||||||
|
|
||||||
// Dev configuration - only hardcoded value we need
|
// 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 {
|
interface ShapeState {
|
||||||
parentId: TLShapeId | null;
|
parentId: TLShapeId | null;
|
||||||
@ -29,8 +33,8 @@ interface NodeResponse {
|
|||||||
|
|
||||||
interface NodeDataResponse {
|
interface NodeDataResponse {
|
||||||
__primarylabel__: string;
|
__primarylabel__: string;
|
||||||
unique_id: string;
|
uuid_string: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
created: string;
|
created: string;
|
||||||
merged: string;
|
merged: string;
|
||||||
state: ShapeState | null;
|
state: ShapeState | null;
|
||||||
@ -47,7 +51,7 @@ interface DefaultNodeResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
node: {
|
node: {
|
||||||
id: string;
|
id: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
data: NodeDataResponse;
|
data: NodeDataResponse;
|
||||||
@ -195,7 +199,7 @@ export class UserNeoDBService {
|
|||||||
} as CCCalendarNodeProps;
|
} as CCCalendarNodeProps;
|
||||||
logger.debug('neo4j-service', '✅ Found calendar node', {
|
logger.debug('neo4j-service', '✅ Found calendar node', {
|
||||||
nodeId: calendarNode.id,
|
nodeId: calendarNode.id,
|
||||||
tldraw_snapshot: calendarNode.data.tldraw_snapshot
|
node_storage_path: calendarNode.data.node_storage_path
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.debug('neo4j-service', 'ℹ️ No calendar node found');
|
logger.debug('neo4j-service', 'ℹ️ No calendar node found');
|
||||||
@ -224,7 +228,7 @@ export class UserNeoDBService {
|
|||||||
} as CCTeacherNodeProps;
|
} as CCTeacherNodeProps;
|
||||||
logger.debug('neo4j-service', '✅ Found teacher node', {
|
logger.debug('neo4j-service', '✅ Found teacher node', {
|
||||||
nodeId: teacherNode.id,
|
nodeId: teacherNode.id,
|
||||||
tldraw_snapshot: teacherNode.data.tldraw_snapshot,
|
node_storage_path: teacherNode.data.node_storage_path,
|
||||||
userDbName,
|
userDbName,
|
||||||
workerDbName
|
workerDbName
|
||||||
});
|
});
|
||||||
@ -242,9 +246,9 @@ export class UserNeoDBService {
|
|||||||
hasCalendar: !!processedNodes.connectedNodes.calendar,
|
hasCalendar: !!processedNodes.connectedNodes.calendar,
|
||||||
hasTeacher: !!processedNodes.connectedNodes.teacher,
|
hasTeacher: !!processedNodes.connectedNodes.teacher,
|
||||||
teacherData: 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,
|
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
|
} : null
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -259,8 +263,8 @@ export class UserNeoDBService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getUserDatabaseName(userType: string, username: string): string {
|
static getUserDatabaseName(userType: string, identifier: string): string {
|
||||||
return DatabaseNameService.getUserPrivateDB(userType, username);
|
return DatabaseNameService.getUserPrivateDB(userType, identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getSchoolDatabaseName(schoolId: string): string {
|
static getSchoolDatabaseName(schoolId: string): string {
|
||||||
@ -268,10 +272,10 @@ export class UserNeoDBService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getDefaultSchoolDatabaseName(): string {
|
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 {
|
try {
|
||||||
logger.debug('neo4j-service', '🔄 Fetching node data', { nodeId, dbName });
|
logger.debug('neo4j-service', '🔄 Fetching node data', { nodeId, dbName });
|
||||||
|
|
||||||
@ -279,11 +283,11 @@ export class UserNeoDBService {
|
|||||||
status: string;
|
status: string;
|
||||||
node: {
|
node: {
|
||||||
node_type: string;
|
node_type: string;
|
||||||
node_data: NodeResponse['nodes']['userNode'];
|
node_data: NodeDataResponse;
|
||||||
};
|
};
|
||||||
}>('/database/tools/get-node', {
|
}>('/database/tools/get-node', {
|
||||||
params: {
|
params: {
|
||||||
unique_id: nodeId,
|
uuid_string: nodeId,
|
||||||
db_name: dbName
|
db_name: dbName
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -300,18 +304,78 @@ export class UserNeoDBService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getNodeDatabaseName(node: NavigationNode): string {
|
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 the node path starts with /node_filesystem/users/, it's in a user database
|
||||||
if (node.tldraw_snapshot.startsWith('/node_filesystem/users/')) {
|
if (node.node_storage_path.startsWith('users/')) {
|
||||||
const parts = node.tldraw_snapshot.split('/');
|
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)
|
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
|
||||||
|
if (parts.length >= 4) {
|
||||||
return parts[3];
|
return parts[3];
|
||||||
}
|
}
|
||||||
// For school/worker nodes, extract from the path or use a default
|
logger.warn('neo4j-service', '⚠️ Unexpected user path format', { path: node.node_storage_path });
|
||||||
if (node.tldraw_snapshot.includes('/schools/')) {
|
return 'cc.users';
|
||||||
return `cc.institutes.${DEV_SCHOOL_UUID}`;
|
|
||||||
}
|
}
|
||||||
// Default to user database if we can't determine
|
|
||||||
return node.tldraw_snapshot.split('/')[3];
|
// 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> {
|
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) {
|
if (response.data?.status === 'success' && response.data.node) {
|
||||||
return {
|
return {
|
||||||
id: response.data.node.id,
|
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,
|
type: response.data.node.type,
|
||||||
label: response.data.node.label,
|
label: response.data.node.label,
|
||||||
data: response.data.node.data
|
data: response.data.node.data
|
||||||
|
|||||||
@ -10,8 +10,7 @@ export const initializeApp = () => {
|
|||||||
|
|
||||||
logger.debug('app', '🚀 App initializing', {
|
logger.debug('app', '🚀 App initializing', {
|
||||||
isDevMode: import.meta.env.VITE_DEV === 'true',
|
isDevMode: import.meta.env.VITE_DEV === 'true',
|
||||||
environment: import.meta.env.MODE,
|
environment: import.meta.env.MODE
|
||||||
appName: import.meta.env.VITE_APP_NAME
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set the app element for react-modal
|
// Set the app element for react-modal
|
||||||
|
|||||||
89
src/services/provisioningService.ts
Normal file
89
src/services/provisioningService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// External imports
|
// External imports
|
||||||
import { loadSnapshot, TLStore, getSnapshot } from '@tldraw/tldraw';
|
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
||||||
import axios from '../../axiosConfig';
|
import axios from '../../axiosConfig';
|
||||||
import logger from '../../debugConfig';
|
import logger from '../../debugConfig';
|
||||||
import { SharedStoreService } from './sharedStoreService';
|
import { SharedStoreService } from './sharedStoreService';
|
||||||
@ -13,13 +13,14 @@ export interface LoadingState {
|
|||||||
|
|
||||||
const EMPTY_NODE: NavigationNode = {
|
const EMPTY_NODE: NavigationNode = {
|
||||||
id: '',
|
id: '',
|
||||||
tldraw_snapshot: '',
|
node_storage_path: '',
|
||||||
type: '',
|
type: '',
|
||||||
label: ''
|
label: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
export class NavigationSnapshotService {
|
export class NavigationSnapshotService {
|
||||||
private store: TLStore;
|
private store: TLStore;
|
||||||
|
private editor: Editor | null = null;
|
||||||
private currentNodePath: string | null = null;
|
private currentNodePath: string | null = null;
|
||||||
private isAutoSaveEnabled = true;
|
private isAutoSaveEnabled = true;
|
||||||
private isSaving = false;
|
private isSaving = false;
|
||||||
@ -27,10 +28,19 @@ export class NavigationSnapshotService {
|
|||||||
private pendingOperation: { save?: string; load?: string } | null = null;
|
private pendingOperation: { save?: string; load?: string } | null = null;
|
||||||
private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor(store: TLStore) {
|
constructor(store: TLStore, editor?: Editor) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
|
this.editor = editor || null;
|
||||||
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
|
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,
|
dbName: string,
|
||||||
store: TLStore,
|
store: TLStore,
|
||||||
setLoadingState: (state: LoadingState) => void,
|
setLoadingState: (state: LoadingState) => void,
|
||||||
sharedStore?: SharedStoreService
|
sharedStore?: SharedStoreService,
|
||||||
|
editor?: Editor
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
setLoadingState({ status: 'loading', error: '' });
|
||||||
@ -54,7 +65,7 @@ export class NavigationSnapshotService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
'/database/tldraw_fs/get_tldraw_node_file', {
|
'/database/tldraw_supabase/get_tldraw_node_file', {
|
||||||
params: {
|
params: {
|
||||||
path: this.replaceBackslashes(nodePath),
|
path: this.replaceBackslashes(nodePath),
|
||||||
db_name: dbName
|
db_name: dbName
|
||||||
@ -63,13 +74,127 @@ export class NavigationSnapshotService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = response.data;
|
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) {
|
if (snapshot && snapshot.document && snapshot.session) {
|
||||||
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
|
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
|
||||||
|
|
||||||
if (sharedStore) {
|
if (sharedStore) {
|
||||||
await sharedStore.loadSnapshot(snapshot, setLoadingState);
|
await sharedStore.loadSnapshot(snapshot, setLoadingState);
|
||||||
} else {
|
} 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);
|
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -100,8 +225,24 @@ export class NavigationSnapshotService {
|
|||||||
|
|
||||||
const snapshot = getSnapshot(store);
|
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(
|
const response = await axios.post(
|
||||||
'/database/tldraw_fs/set_tldraw_node_file',
|
'/database/tldraw_supabase/set_tldraw_node_file',
|
||||||
snapshot,
|
snapshot,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
@ -177,34 +318,37 @@ export class NavigationSnapshotService {
|
|||||||
const dbName = user.user_db_name;
|
const dbName = user.user_db_name;
|
||||||
|
|
||||||
logger.debug('snapshot-service', '📥 Loading snapshot', {
|
logger.debug('snapshot-service', '📥 Loading snapshot', {
|
||||||
nodePath: node.tldraw_snapshot,
|
nodePath: node.node_storage_path,
|
||||||
dbName,
|
dbName,
|
||||||
userType: user.user_type,
|
userType: user.user_type,
|
||||||
username: user.username
|
username: user.username
|
||||||
});
|
});
|
||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
node.tldraw_snapshot,
|
node.node_storage_path,
|
||||||
dbName,
|
dbName,
|
||||||
this.store,
|
this.store,
|
||||||
(state: LoadingState) => {
|
(state: LoadingState) => {
|
||||||
if (state.status === 'ready') {
|
if (state.status === 'ready') {
|
||||||
this.currentNodePath = node.tldraw_snapshot;
|
this.currentNodePath = node.node_storage_path;
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
|
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') {
|
} else if (state.status === 'error') {
|
||||||
logger.error('snapshot-service', '❌ Error in load callback', {
|
logger.error('snapshot-service', '❌ Error in load callback', {
|
||||||
error: state.error,
|
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) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
|
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
nodePath: node.tldraw_snapshot
|
nodePath: node.node_storage_path
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@ -240,16 +384,16 @@ export class NavigationSnapshotService {
|
|||||||
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
|
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
|
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
|
||||||
from: fromNode.tldraw_snapshot,
|
from: fromNode.node_storage_path,
|
||||||
to: toNode.tldraw_snapshot,
|
to: toNode.node_storage_path,
|
||||||
currentPath: this.currentNodePath
|
currentPath: this.currentNodePath
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we're already in a navigation operation, queue this one
|
// If we're already in a navigation operation, queue this one
|
||||||
if (this.isSaving || this.isLoading) {
|
if (this.isSaving || this.isLoading) {
|
||||||
this.pendingOperation = {
|
this.pendingOperation = {
|
||||||
save: fromNode.tldraw_snapshot || undefined,
|
save: fromNode.node_storage_path || undefined,
|
||||||
load: toNode.tldraw_snapshot
|
load: toNode.node_storage_path
|
||||||
};
|
};
|
||||||
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
|
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
|
||||||
return;
|
return;
|
||||||
@ -261,10 +405,10 @@ export class NavigationSnapshotService {
|
|||||||
logger.debug('snapshot-service', '🧹 Cleared current node path');
|
logger.debug('snapshot-service', '🧹 Cleared current node path');
|
||||||
|
|
||||||
// Load the new node's snapshot
|
// Load the new node's snapshot
|
||||||
if (toNode.tldraw_snapshot) {
|
if (toNode.node_storage_path) {
|
||||||
await this.loadSnapshotForNode(toNode);
|
await this.loadSnapshotForNode(toNode);
|
||||||
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
|
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;
|
const operation = this.pendingOperation;
|
||||||
this.pendingOperation = null;
|
this.pendingOperation = null;
|
||||||
await this.handleNavigationStart(
|
await this.handleNavigationStart(
|
||||||
operation.save ? { ...EMPTY_NODE, tldraw_snapshot: operation.save } : null,
|
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null,
|
||||||
operation.load ? { ...EMPTY_NODE, tldraw_snapshot: operation.load } : null
|
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null
|
||||||
);
|
);
|
||||||
logger.debug('snapshot-service', '✅ Completed pending operation');
|
logger.debug('snapshot-service', '✅ Completed pending operation');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
|
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
fromPath: fromNode.tldraw_snapshot,
|
fromPath: fromNode.node_storage_path,
|
||||||
toPath: toNode.tldraw_snapshot
|
toPath: toNode.node_storage_path
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -303,6 +447,8 @@ export class NavigationSnapshotService {
|
|||||||
async forceSaveCurrentNode(): Promise<void> {
|
async forceSaveCurrentNode(): Promise<void> {
|
||||||
if (this.currentNodePath) {
|
if (this.currentNodePath) {
|
||||||
await this.saveCurrentSnapshot(this.currentNodePath);
|
await this.saveCurrentSnapshot(this.currentNodePath);
|
||||||
|
} else {
|
||||||
|
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
|||||||
|
|
||||||
logger.debug('context-switch', '✨ Default node fetched', {
|
logger.debug('context-switch', '✨ Default node fetched', {
|
||||||
nodeId: defaultNode.id,
|
nodeId: defaultNode.id,
|
||||||
tldraw_snapshot: defaultNode.tldraw_snapshot,
|
node_storage_path: defaultNode.node_storage_path,
|
||||||
type: defaultNode.type
|
type: defaultNode.type
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -353,7 +353,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
|||||||
|
|
||||||
const node: NavigationNode = {
|
const node: NavigationNode = {
|
||||||
id: nodeId,
|
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,
|
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
|
||||||
type: nodeData.node_type
|
type: nodeData.node_type
|
||||||
};
|
};
|
||||||
@ -361,7 +361,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
|||||||
logger.debug('navigation', '📍 Adding new node to history', {
|
logger.debug('navigation', '📍 Adding new node to history', {
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
tldraw_snapshot: node.tldraw_snapshot
|
node_storage_path: node.node_storage_path
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to history and update state
|
// Add to history and update state
|
||||||
@ -414,7 +414,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
|||||||
if (nodeData) {
|
if (nodeData) {
|
||||||
const node: NavigationNode = {
|
const node: NavigationNode = {
|
||||||
id: currentState.node.id,
|
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,
|
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
|
||||||
type: nodeData.node_type
|
type: nodeData.node_type
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
const appProtocol = import.meta.env.VITE_APP_PROTOCOL;
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseUrl = `${appProtocol}://${import.meta.env.VITE_SUPABASE_URL}`;
|
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
logger.info('supabase-client', '🔄 Supabase configuration', {
|
logger.info('supabase-client', '🔄 Supabase configuration', {
|
||||||
@ -42,6 +41,10 @@ const getSupabaseClient = () => {
|
|||||||
'X-Client-Info': 'classroom-copilot',
|
'X-Client-Info': 'classroom-copilot',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Allow JWT issuer mismatch for local development
|
||||||
|
db: {
|
||||||
|
schema: 'public'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,10 @@ import { CCNodeTypes } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
|||||||
export interface NodeResponse {
|
export interface NodeResponse {
|
||||||
node_data: {
|
node_data: {
|
||||||
__primarylabel__: keyof CCNodeTypes;
|
__primarylabel__: keyof CCNodeTypes;
|
||||||
unique_id: string;
|
uuid_string: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
created: string;
|
created: string;
|
||||||
merged: string;
|
merged: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@ -16,7 +16,7 @@ export interface NodeResponse {
|
|||||||
|
|
||||||
export interface ConnectedNodeResponse {
|
export interface ConnectedNodeResponse {
|
||||||
id: string;
|
id: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export type NodeData = {
|
|||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
isLocked: boolean;
|
isLocked: boolean;
|
||||||
__primarylabel__: string;
|
__primarylabel__: string;
|
||||||
unique_id: string;
|
uuid_string: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
[key: string]: string | number | boolean | null | ShapeState | Record<string, unknown> | undefined;
|
[key: string]: string | number | boolean | null | ShapeState | Record<string, unknown> | undefined;
|
||||||
}
|
}
|
||||||
@ -198,13 +198,13 @@ export interface ContextDefinition {
|
|||||||
// Navigation Node Types
|
// Navigation Node Types
|
||||||
export interface NavigationNode {
|
export interface NavigationNode {
|
||||||
id: string;
|
id: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: string;
|
type: string;
|
||||||
context?: NavigationContextState;
|
context?: NavigationContextState;
|
||||||
data?: {
|
data?: {
|
||||||
unique_id: string;
|
uuid_string: string;
|
||||||
tldraw_snapshot: string;
|
node_storage_path: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@ -50,7 +50,7 @@ const TeacherNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) =>
|
|||||||
'Teacher ID': data.teacher_code,
|
'Teacher ID': data.teacher_code,
|
||||||
'Teacher Email': data.teacher_email,
|
'Teacher Email': data.teacher_email,
|
||||||
}}
|
}}
|
||||||
id={data.unique_id as string}
|
id={data.uuid_string as string}
|
||||||
type='userNode'
|
type='userNode'
|
||||||
dragging={false}
|
dragging={false}
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
@ -70,7 +70,7 @@ const TeacherTimetableNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirecti
|
|||||||
data={{
|
data={{
|
||||||
label: data.label,
|
label: data.label,
|
||||||
}}
|
}}
|
||||||
id={data.unique_id as string}
|
id={data.uuid_string as string}
|
||||||
type='userNode'
|
type='userNode'
|
||||||
dragging={false}
|
dragging={false}
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
@ -93,7 +93,7 @@ const SubjectClassNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }
|
|||||||
'Subject': data.subject,
|
'Subject': data.subject,
|
||||||
'Subject Code': data.subject_code,
|
'Subject Code': data.subject_code,
|
||||||
}}
|
}}
|
||||||
id={data.unique_id as string}
|
id={data.uuid_string as string}
|
||||||
type='userNode'
|
type='userNode'
|
||||||
dragging={false}
|
dragging={false}
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
|
|||||||
216
src/utils/folderPicker.ts
Normal file
216
src/utils/folderPicker.ts
Normal 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -57,7 +57,7 @@ export const CalendarComponent: React.FC<CalendarComponentProps> = ({ shape }) =
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const fetchedEvents = await TimetableNeoDBService.fetchTeacherTimetableEvents(
|
const fetchedEvents = await TimetableNeoDBService.fetchTeacherTimetableEvents(
|
||||||
workerNode.nodeData.unique_id,
|
workerNode.nodeData.uuid_string,
|
||||||
workerDbName || ''
|
workerDbName || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -134,13 +134,13 @@ export const EventDetailsDialog = ({
|
|||||||
setFileLoadingState
|
setFileLoadingState
|
||||||
}: EventDetailsDialogProps) => {
|
}: EventDetailsDialogProps) => {
|
||||||
const handleOpenFile = () => {
|
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')
|
console.error('❌ Failed to open tldraw file - missing snapshot or db name')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpenFile(
|
onOpenFile(
|
||||||
selectedEvent.extendedProps.tldraw_snapshot,
|
selectedEvent.extendedProps.node_storage_path,
|
||||||
workerDbName,
|
workerDbName,
|
||||||
editor,
|
editor,
|
||||||
setFileLoadingState
|
setFileLoadingState
|
||||||
@ -166,7 +166,7 @@ export const EventDetailsDialog = ({
|
|||||||
<p style={{color: 'red'}}>Error: {fileLoadingState.error}</p>
|
<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}>
|
<TldrawUiButton type="normal" onClick={handleOpenFile}>
|
||||||
<TldrawUiButtonLabel>
|
<TldrawUiButtonLabel>
|
||||||
Open Tldraw File <FaExternalLinkAlt style={{ marginLeft: '8px' }} />
|
Open Tldraw File <FaExternalLinkAlt style={{ marginLeft: '8px' }} />
|
||||||
|
|||||||
@ -32,19 +32,19 @@ export class CCSchoolNodeShapeUtil extends CCBaseShapeUtil<CCSchoolNodeShape> {
|
|||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="School Name"
|
label="School Name"
|
||||||
value={shape.props.school_name}
|
value={shape.props.name}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="School Website"
|
label="School Website"
|
||||||
value={shape.props.school_website}
|
value={shape.props.website}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="School UUID"
|
label="School UUID"
|
||||||
value={shape.props.school_uuid}
|
value={shape.props.uuid_string}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -51,12 +51,12 @@ export class CCTeacherNodeShapeUtil extends CCBaseShapeUtil<CCTeacherNodeShape>
|
|||||||
{ label: 'Teacher Name', value: props.teacher_name_formal },
|
{ label: 'Teacher Name', value: props.teacher_name_formal },
|
||||||
{ label: 'Teacher Code', value: props.teacher_code },
|
{ label: 'Teacher Code', value: props.teacher_code },
|
||||||
{ label: 'Email', value: props.teacher_email },
|
{ label: 'Email', value: props.teacher_email },
|
||||||
{ label: 'Node Snapshot', value: props.tldraw_snapshot }
|
{ label: 'Node Snapshot', value: props.node_storage_path }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
{defaultComponent && <DefaultNodeComponent tldraw_snapshot={props.tldraw_snapshot} />}
|
{defaultComponent && <DefaultNodeComponent node_storage_path={props.node_storage_path} />}
|
||||||
{properties.map((prop, index) => (
|
{properties.map((prop, index) => (
|
||||||
<div key={index} style={styles.property.wrapper}>
|
<div key={index} style={styles.property.wrapper}>
|
||||||
<span style={styles.property.label}>{prop.label}:</span>
|
<span style={styles.property.label}>{prop.label}:</span>
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export class CCUserNodeShapeUtil extends CCBaseShapeUtil<CCUserNodeShape> {
|
|||||||
{ label: 'User Email', value: props.user_email },
|
{ label: 'User Email', value: props.user_email },
|
||||||
{ label: 'User Type', value: props.user_type },
|
{ label: 'User Type', value: props.user_type },
|
||||||
{ label: 'User ID', value: props.user_id },
|
{ 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: 'Worker Node Data', value: props.worker_node_data }
|
||||||
] : [
|
] : [
|
||||||
{ label: 'User Name', value: props.user_name },
|
{ label: 'User Name', value: props.user_name },
|
||||||
@ -60,7 +60,7 @@ export class CCUserNodeShapeUtil extends CCBaseShapeUtil<CCUserNodeShape> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
{defaultComponent && <DefaultNodeComponent tldraw_snapshot={props.tldraw_snapshot} />}
|
{defaultComponent && <DefaultNodeComponent node_storage_path={props.node_storage_path} />}
|
||||||
{properties.map((prop, index) => (
|
{properties.map((prop, index) => (
|
||||||
<div key={index} style={styles.property.wrapper}>
|
<div key={index} style={styles.property.wrapper}>
|
||||||
<span style={styles.property.label}>{prop.label}:</span>
|
<span style={styles.property.label}>{prop.label}:</span>
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
|
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
|
||||||
import { CCBaseShape } from '../cc-types'
|
import { CCBaseShape } from '../cc-types'
|
||||||
import { NodeProperty, formatDate } from './cc-graph-shared'
|
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 { getNodeStyles } from './cc-graph-styles'
|
||||||
import { NODE_THEMES, NODE_TYPE_THEMES } 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'
|
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 type = 'cc-user-timetable-lesson-node' as const
|
||||||
static props = ccGraphShapeProps['cc-user-timetable-lesson-node']
|
static props = ccGraphShapeProps['cc-user-timetable-lesson-node']
|
||||||
|
|
||||||
getDefaultProps(): CCUserTimetableLessonNodeShape['props'] {
|
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
|
||||||
const defaultProps = getDefaultCCUserTimetableLessonNodeProps() as CCUserTimetableLessonNodeShape['props']
|
const defaultProps = getDefaultCCTimetableLessonNodeProps() as CCTimetableLessonNodeShape['props']
|
||||||
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCUserTimetableLessonNodeShapeUtil.type]]
|
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
|
||||||
return {
|
return {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
headerColor: theme.headerColor,
|
headerColor: theme.headerColor,
|
||||||
@ -27,7 +27,7 @@ export class CCUserTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCUserTi
|
|||||||
// Override to nullify the default node component
|
// Override to nullify the default node component
|
||||||
DefaultComponent = () => null
|
DefaultComponent = () => null
|
||||||
|
|
||||||
renderContent = (shape: CCUserTimetableLessonNodeShape) => {
|
renderContent = (shape: CCTimetableLessonNodeShape) => {
|
||||||
const styles = getNodeStyles(shape.type)
|
const styles = getNodeStyles(shape.type)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -14,8 +14,8 @@ const stateProps = T.object({
|
|||||||
const graphBaseProps = {
|
const graphBaseProps = {
|
||||||
...baseShapeProps,
|
...baseShapeProps,
|
||||||
__primarylabel__: T.string,
|
__primarylabel__: T.string,
|
||||||
unique_id: T.string,
|
uuid_string: T.string,
|
||||||
tldraw_snapshot: T.string,
|
node_storage_path: T.string,
|
||||||
created: T.string,
|
created: T.string,
|
||||||
merged: T.string,
|
merged: T.string,
|
||||||
state: T.optional(stateProps.nullable()),
|
state: T.optional(stateProps.nullable()),
|
||||||
@ -84,9 +84,8 @@ export const ccGraphShapeProps = {
|
|||||||
},
|
},
|
||||||
'cc-school-node': {
|
'cc-school-node': {
|
||||||
...graphBaseProps,
|
...graphBaseProps,
|
||||||
school_uuid: T.string,
|
name: T.string,
|
||||||
school_name: T.string,
|
website: T.string,
|
||||||
school_website: T.string,
|
|
||||||
},
|
},
|
||||||
'cc-department-node': {
|
'cc-department-node': {
|
||||||
...graphBaseProps,
|
...graphBaseProps,
|
||||||
@ -283,8 +282,8 @@ export const getDefaultBaseProps = () => ({
|
|||||||
backgroundColor: '#f0f0f0' as string,
|
backgroundColor: '#f0f0f0' as string,
|
||||||
title: 'Untitled' as string,
|
title: 'Untitled' as string,
|
||||||
isLocked: false as boolean,
|
isLocked: false as boolean,
|
||||||
unique_id: '' as string,
|
uuid_string: '' as string,
|
||||||
tldraw_snapshot: '' as string,
|
node_storage_path: '' as string,
|
||||||
created: '' as string,
|
created: '' as string,
|
||||||
merged: '' as string,
|
merged: '' as string,
|
||||||
state: {
|
state: {
|
||||||
@ -383,9 +382,8 @@ export const getDefaultCCSchoolNodeProps = () => ({
|
|||||||
...getDefaultBaseProps(),
|
...getDefaultBaseProps(),
|
||||||
title: 'School',
|
title: 'School',
|
||||||
__primarylabel__: 'School',
|
__primarylabel__: 'School',
|
||||||
school_uuid: '',
|
name: '',
|
||||||
school_name: '',
|
website: '',
|
||||||
school_website: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getDefaultCCDepartmentNodeProps = () => ({
|
export const getDefaultCCDepartmentNodeProps = () => ({
|
||||||
@ -642,16 +640,3 @@ export const getDefaultCCUserTeacherTimetableNodeProps = () => ({
|
|||||||
school_db_name: '',
|
school_db_name: '',
|
||||||
school_timetable_id: '',
|
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: '',
|
|
||||||
})
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CC
|
|||||||
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
||||||
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
||||||
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
||||||
import { CCUserTimetableLessonNodeShape, CCUserTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
|
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
||||||
|
|
||||||
// Create a const object with all node types
|
// Create a const object with all node types
|
||||||
export const NODE_SHAPE_TYPES = {
|
export const NODE_SHAPE_TYPES = {
|
||||||
@ -75,7 +75,7 @@ export const NODE_SHAPE_TYPES = {
|
|||||||
PLANNED_LESSON: CCPlannedLessonNodeShapeUtil.type,
|
PLANNED_LESSON: CCPlannedLessonNodeShapeUtil.type,
|
||||||
DEPARTMENT_STRUCTURE: CCDepartmentStructureNodeShapeUtil.type,
|
DEPARTMENT_STRUCTURE: CCDepartmentStructureNodeShapeUtil.type,
|
||||||
USER_TEACHER_TIMETABLE: CCUserTeacherTimetableNodeShapeUtil.type,
|
USER_TEACHER_TIMETABLE: CCUserTeacherTimetableNodeShapeUtil.type,
|
||||||
USER_TIMETABLE_LESSON: CCUserTimetableLessonNodeShapeUtil.type,
|
USER_TIMETABLE_LESSON: CCTimetableLessonNodeShapeUtil.type,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Create the type from the const object's values
|
// Create the type from the const object's values
|
||||||
@ -119,7 +119,7 @@ export type AllNodeShapes =
|
|||||||
| CCPlannedLessonNodeShape
|
| CCPlannedLessonNodeShape
|
||||||
| CCDepartmentStructureNodeShape
|
| CCDepartmentStructureNodeShape
|
||||||
| CCUserTeacherTimetableNodeShape
|
| CCUserTeacherTimetableNodeShape
|
||||||
| CCUserTimetableLessonNodeShape;
|
| CCTimetableLessonNodeShape;
|
||||||
|
|
||||||
// Export all shape utils in an object for easy access
|
// Export all shape utils in an object for easy access
|
||||||
export const ShapeUtils = {
|
export const ShapeUtils = {
|
||||||
@ -159,7 +159,7 @@ export const ShapeUtils = {
|
|||||||
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
||||||
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
||||||
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
||||||
[CCUserTimetableLessonNodeShapeUtil.type]: CCUserTimetableLessonNodeShapeUtil,
|
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Add a type guard to check if a shape is a valid node shape
|
// Add a type guard to check if a shape is a valid node shape
|
||||||
|
|||||||
@ -177,8 +177,8 @@ export const checkDefaultComponent = (defaultComponent: boolean | { action: { la
|
|||||||
|
|
||||||
// Base component for all graph nodes
|
// Base component for all graph nodes
|
||||||
interface DefaultNodeComponentProps {
|
interface DefaultNodeComponentProps {
|
||||||
tldraw_snapshot: string
|
node_storage_path: string
|
||||||
onInspect?: (tldraw_snapshot: string) => void
|
onInspect?: (node_storage_path: string) => void
|
||||||
customAction?: {
|
customAction?: {
|
||||||
label: string
|
label: string
|
||||||
handler: () => void
|
handler: () => void
|
||||||
@ -186,13 +186,13 @@ interface DefaultNodeComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DefaultNodeComponent: React.FC<DefaultNodeComponentProps> = ({
|
export const DefaultNodeComponent: React.FC<DefaultNodeComponentProps> = ({
|
||||||
tldraw_snapshot,
|
node_storage_path,
|
||||||
onInspect = () => console.log(`Inspecting node at path: ${tldraw_snapshot}`),
|
onInspect = () => console.log(`Inspecting node at path: ${node_storage_path}`),
|
||||||
customAction
|
customAction
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div style={SHARED_NODE_STYLES.defaultComponent.container}>
|
<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
|
Inspect
|
||||||
</button>
|
</button>
|
||||||
{customAction && (
|
{customAction && (
|
||||||
|
|||||||
@ -15,8 +15,8 @@ export interface ShapeState {
|
|||||||
|
|
||||||
export type CCGraphShapeProps = CCBaseProps & {
|
export type CCGraphShapeProps = CCBaseProps & {
|
||||||
__primarylabel__: string
|
__primarylabel__: string
|
||||||
unique_id: string
|
uuid_string: string
|
||||||
tldraw_snapshot: string
|
node_storage_path: string
|
||||||
created: string
|
created: string
|
||||||
merged: string
|
merged: string
|
||||||
state: ShapeState | null | undefined
|
state: ShapeState | null | undefined
|
||||||
@ -26,8 +26,8 @@ export type CCGraphShapeProps = CCBaseProps & {
|
|||||||
// Define the base shape type for graph shapes
|
// Define the base shape type for graph shapes
|
||||||
export type CCGraphShape = CCBaseShape & TLBaseShape<GraphShapeType, {
|
export type CCGraphShape = CCBaseShape & TLBaseShape<GraphShapeType, {
|
||||||
__primarylabel__: CCGraphShapeProps['__primarylabel__']
|
__primarylabel__: CCGraphShapeProps['__primarylabel__']
|
||||||
unique_id: CCGraphShapeProps['unique_id']
|
uuid_string: CCGraphShapeProps['uuid_string']
|
||||||
tldraw_snapshot: CCGraphShapeProps['tldraw_snapshot']
|
node_storage_path: CCGraphShapeProps['node_storage_path']
|
||||||
created: CCGraphShapeProps['created']
|
created: CCGraphShapeProps['created']
|
||||||
merged: CCGraphShapeProps['merged']
|
merged: CCGraphShapeProps['merged']
|
||||||
state: CCGraphShapeProps['state']
|
state: CCGraphShapeProps['state']
|
||||||
@ -95,9 +95,8 @@ export type CCCalendarTimeChunkNodeProps = CCGraphShapeProps & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CCSchoolNodeProps = CCGraphShapeProps & {
|
export type CCSchoolNodeProps = CCGraphShapeProps & {
|
||||||
school_uuid: string
|
name: string
|
||||||
school_name: string
|
website: string
|
||||||
school_website: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CCDepartmentNodeProps = CCGraphShapeProps & {
|
export type CCDepartmentNodeProps = CCGraphShapeProps & {
|
||||||
@ -195,14 +194,6 @@ export type CCTeacherTimetableNodeProps = CCGraphShapeProps & {
|
|||||||
end_date: string
|
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 & {
|
export type CCPlannedLessonNodeProps = CCGraphShapeProps & {
|
||||||
date: string
|
date: string
|
||||||
start_time: string
|
start_time: string
|
||||||
@ -277,7 +268,7 @@ export type CCUserTeacherTimetableNodeProps = CCGraphShapeProps & {
|
|||||||
school_timetable_id: string
|
school_timetable_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CCUserTimetableLessonNodeProps = CCGraphShapeProps & {
|
export type CCTimetableLessonNodeProps = CCGraphShapeProps & {
|
||||||
subject_class: string
|
subject_class: string
|
||||||
date: string
|
date: string
|
||||||
start_time: string
|
start_time: string
|
||||||
@ -324,7 +315,7 @@ export type CCNodeTypes = {
|
|||||||
SubjectClass: { props: CCSubjectClassNodeProps }
|
SubjectClass: { props: CCSubjectClassNodeProps }
|
||||||
DepartmentStructure: { props: CCDepartmentStructureNodeProps }
|
DepartmentStructure: { props: CCDepartmentStructureNodeProps }
|
||||||
UserTeacherTimetable: { props: CCUserTeacherTimetableNodeProps }
|
UserTeacherTimetable: { props: CCUserTeacherTimetableNodeProps }
|
||||||
UserTimetableLesson: { props: CCUserTimetableLessonNodeProps }
|
UserTimetableLesson: { props: CCTimetableLessonNodeProps }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get shape type from node type
|
// 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
|
// Helper function to get allowed props from node type
|
||||||
export const getAllowedProps = (): string[] => {
|
export const getAllowedProps = (): string[] => {
|
||||||
return ['__primarylabel__', 'unique_id'];
|
return ['__primarylabel__', 'uuid_string'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get node configuration
|
// Helper function to get node configuration
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export const graphState = {
|
|||||||
const updatedShapeIds: string[] = [];
|
const updatedShapeIds: string[] = [];
|
||||||
|
|
||||||
nodes.forEach((node, index) => {
|
nodes.forEach((node, index) => {
|
||||||
if (!node.props?.unique_id) return;
|
if (!node.props?.uuid_string) return;
|
||||||
|
|
||||||
const row = Math.floor(index / gridColumns);
|
const row = Math.floor(index / gridColumns);
|
||||||
const col = index % gridColumns;
|
const col = index % gridColumns;
|
||||||
@ -60,13 +60,13 @@ export const graphState = {
|
|||||||
const x = startX + (col * (GRID_CELL_SIZE + GRID_PADDING));
|
const x = startX + (col * (GRID_CELL_SIZE + GRID_PADDING));
|
||||||
const y = startY + (row * (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());
|
updatedShapeIds.push(shapeId.toString());
|
||||||
|
|
||||||
// Update both our internal state and the editor
|
// Update both our internal state and the editor
|
||||||
node.x = x;
|
node.x = x;
|
||||||
node.y = y;
|
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
|
// Only create if the shape doesn't exist in our tracking
|
||||||
if (!graphState.shapeIds.has(shapeId.toString())) {
|
if (!graphState.shapeIds.has(shapeId.toString())) {
|
||||||
@ -123,12 +123,12 @@ export const graphState = {
|
|||||||
addNode: (shape: AllNodeShapes) => {
|
addNode: (shape: AllNodeShapes) => {
|
||||||
logger.debug('graphStateUtil', '🔍 Adding shape to graphState:', { shape });
|
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 });
|
logger.error('graphStateUtil', '❌ Invalid shape data', { shape });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = shape.props.unique_id;
|
const id = shape.props.uuid_string;
|
||||||
const shapeId = createShapeId(id).toString();
|
const shapeId = createShapeId(id).toString();
|
||||||
|
|
||||||
// Track the shape ID
|
// Track the shape ID
|
||||||
@ -208,7 +208,7 @@ export const graphState = {
|
|||||||
|
|
||||||
getShapeByUniqueId: (uniqueId: string) => {
|
getShapeByUniqueId: (uniqueId: string) => {
|
||||||
return Array.from(graphState.nodeData.values()).find(
|
return Array.from(graphState.nodeData.values()).find(
|
||||||
shape => shape.props?.unique_id === uniqueId
|
shape => shape.props?.uuid_string === uniqueId
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
import { getDefaultCCBaseProps, getDefaultCCCalendarProps, getDefaultCCLiveTranscriptionProps, getDefaultCCSettingsProps, getDefaultCCSlideProps, getDefaultCCSlideShowProps, getDefaultCCSlideLayoutBindingProps, getDefaultCCYoutubeEmbedProps, getDefaultCCSearchProps, getDefaultCCWebBrowserProps } from './cc-props'
|
||||||
|
|
||||||
// Export both shape and binding migrations
|
// Export both shape and binding migrations
|
||||||
export const ccBindingMigrations = {
|
export const ccBindingMigrations = {
|
||||||
'cc-slide-layout': {
|
'cc-slide-layout': createBindingPropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createBindingPropsMigrationIds('cc-slide-layout', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'binding') return record
|
|
||||||
if (record.type !== 'cc-slide-layout') return record
|
|
||||||
return {
|
return {
|
||||||
...record,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCSlideLayoutBindingProps(),
|
...getDefaultCCSlideLayoutBindingProps(),
|
||||||
...record.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ccShapeMigrations = {
|
export const ccShapeMigrations = {
|
||||||
base: {
|
base: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-base', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-base') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCBaseProps(),
|
...getDefaultCCBaseProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
calendar: {
|
calendar: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-calendar', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-calendar') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCCalendarProps(),
|
...getDefaultCCCalendarProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
liveTranscription: {
|
liveTranscription: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-live-transcription', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-live-transcription') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCLiveTranscriptionProps(),
|
...getDefaultCCLiveTranscriptionProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
settings: {
|
settings: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-settings', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-settings') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCSettingsProps(),
|
...getDefaultCCSettingsProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
slideshow: {
|
slideshow: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-slideshow', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-slideshow') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCSlideShowProps(),
|
...getDefaultCCSlideShowProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
slide: {
|
slide: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-slide', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-slide') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCSlideProps(),
|
...getDefaultCCSlideProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
'cc-youtube-embed': {
|
'cc-youtube-embed': createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-youtube-embed', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-youtube-embed') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCYoutubeEmbedProps(),
|
...getDefaultCCYoutubeEmbedProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
search: {
|
search: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-search', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-search') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCSearchProps(),
|
...getDefaultCCSearchProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
webBrowser: {
|
webBrowser: createShapePropsMigrationSequence({
|
||||||
firstVersion: 1,
|
sequence: [
|
||||||
currentVersion: 1,
|
{
|
||||||
migrators: {
|
id: createShapePropsMigrationIds('cc-web-browser', { Initial: 1 }).Initial,
|
||||||
1: {
|
up: (props: Record<string, unknown>) => {
|
||||||
up: (record: TLRecord) => {
|
|
||||||
if (record.typeName !== 'shape') return record
|
|
||||||
const shape = record as TLShape
|
|
||||||
if (shape.type !== 'cc-web-browser') return record
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
|
||||||
props: {
|
|
||||||
...getDefaultCCWebBrowserProps(),
|
...getDefaultCCWebBrowserProps(),
|
||||||
...shape.props,
|
...props,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
down: (record: TLRecord) => {
|
|
||||||
return record
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
@ -38,7 +38,7 @@ export const ccShapeProps = {
|
|||||||
subjectClass: T.string,
|
subjectClass: T.string,
|
||||||
color: T.string,
|
color: T.string,
|
||||||
periodCode: T.string,
|
periodCode: T.string,
|
||||||
tldraw_snapshot: T.string.optional()
|
node_storage_path: T.string.optional()
|
||||||
})
|
})
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -50,12 +50,12 @@ export const createUserNodeFromProfile = (
|
|||||||
...getDefaultCCUserNodeProps(),
|
...getDefaultCCUserNodeProps(),
|
||||||
headerColor: theme.headerColor,
|
headerColor: theme.headerColor,
|
||||||
title: userNode.user_email,
|
title: userNode.user_email,
|
||||||
unique_id: userNode.unique_id,
|
uuid_string: userNode.uuid_string,
|
||||||
user_name: userNode.user_name,
|
user_name: userNode.user_name,
|
||||||
user_email: userNode.user_email,
|
user_email: userNode.user_email,
|
||||||
user_type: userNode.user_type,
|
user_type: userNode.user_type,
|
||||||
user_id: userNode.user_id,
|
user_id: userNode.user_id,
|
||||||
path: userNode.tldraw_snapshot,
|
node_storage_path: userNode.node_storage_path,
|
||||||
worker_node_data: userNode.worker_node_data,
|
worker_node_data: userNode.worker_node_data,
|
||||||
state: {
|
state: {
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
console.log('🔍 SCHEMA FILE: Starting schema file execution');
|
||||||
|
|
||||||
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema';
|
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema';
|
||||||
import { createTLSchemaFromUtils, defaultBindingUtils, defaultShapeUtils } from '@tldraw/tldraw';
|
import { createTLSchemaFromUtils, defaultBindingUtils, defaultShapeUtils } from '@tldraw/tldraw';
|
||||||
import { ShapeUtils } from './shapes';
|
import { ShapeUtils } from './shapes';
|
||||||
@ -7,8 +9,7 @@ import { ccGraphMigrations } from './cc-base/cc-graph/cc-graph-migrations';
|
|||||||
import { GraphShapeType } from './cc-base/cc-graph/cc-graph-types';
|
import { GraphShapeType } from './cc-base/cc-graph/cc-graph-types';
|
||||||
|
|
||||||
// Create schema with shape definitions
|
// Create schema with shape definitions
|
||||||
export const customSchema = createTLSchema({
|
const customShapes = {
|
||||||
shapes: {
|
|
||||||
...defaultShapeSchemas,
|
...defaultShapeSchemas,
|
||||||
// Dynamically generate shape schemas from ShapeUtils
|
// Dynamically generate shape schemas from ShapeUtils
|
||||||
...Object.values(ShapeUtils).reduce((acc, util) => ({
|
...Object.values(ShapeUtils).reduce((acc, util) => ({
|
||||||
@ -26,7 +27,15 @@ export const customSchema = createTLSchema({
|
|||||||
migrations: ccGraphMigrations[type as GraphShapeType],
|
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: customShapes,
|
||||||
bindings: {
|
bindings: {
|
||||||
...defaultBindingSchemas,
|
...defaultBindingSchemas,
|
||||||
// Add binding schemas from our custom binding utils
|
// 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)
|
// Create schema from utils (alternative approach)
|
||||||
export const schemaFromUtils = createTLSchemaFromUtils({
|
export const schemaFromUtils = createTLSchemaFromUtils({
|
||||||
shapeUtils: [
|
shapeUtils: [
|
||||||
|
|||||||
@ -41,7 +41,6 @@ import { CCAcademicPeriodNodeShapeUtil } from './cc-base/cc-graph/CCAcademicPeri
|
|||||||
import { CCRegistrationPeriodNodeShapeUtil } from './cc-base/cc-graph/CCRegistrationPeriodNodeShapeUtil'
|
import { CCRegistrationPeriodNodeShapeUtil } from './cc-base/cc-graph/CCRegistrationPeriodNodeShapeUtil'
|
||||||
import { CCDepartmentStructureNodeShapeUtil } from './cc-base/cc-graph/CCDepartmentStructureNodeShapeUtil'
|
import { CCDepartmentStructureNodeShapeUtil } from './cc-base/cc-graph/CCDepartmentStructureNodeShapeUtil'
|
||||||
import { CCUserTeacherTimetableNodeShapeUtil } from './cc-base/cc-graph/CCUserTeacherTimetableNodeShapeUtil'
|
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 { CCSearchShapeUtil } from './cc-base/cc-search/CCSearchShapeUtil'
|
||||||
import { CCWebBrowserShapeUtil } from './cc-base/cc-web-browser/CCWebBrowserUtil'
|
import { CCWebBrowserShapeUtil } from './cc-base/cc-web-browser/CCWebBrowserUtil'
|
||||||
// Define all shape utils in a single object for easy maintenance
|
// Define all shape utils in a single object for easy maintenance
|
||||||
@ -88,7 +87,6 @@ export const ShapeUtils = {
|
|||||||
CCRegistrationPeriodNode: CCRegistrationPeriodNodeShapeUtil,
|
CCRegistrationPeriodNode: CCRegistrationPeriodNodeShapeUtil,
|
||||||
CCDepartmentStructureNode: CCDepartmentStructureNodeShapeUtil,
|
CCDepartmentStructureNode: CCDepartmentStructureNodeShapeUtil,
|
||||||
CCUserTeacherTimetableNode: CCUserTeacherTimetableNodeShapeUtil,
|
CCUserTeacherTimetableNode: CCUserTeacherTimetableNodeShapeUtil,
|
||||||
CCUserTimetableLessonNode: CCUserTimetableLessonNodeShapeUtil,
|
|
||||||
CCSearch: CCSearchShapeUtil,
|
CCSearch: CCSearchShapeUtil,
|
||||||
CCWebBrowser: CCWebBrowserShapeUtil,
|
CCWebBrowser: CCWebBrowserShapeUtil,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import {
|
|||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { CCShapesPanel } from './CCShapesPanel';
|
import { CCShapesPanel } from './CCShapesPanel';
|
||||||
import { CCSlidesPanel } from './CCSlidesPanel';
|
import { CCSlidesPanel } from './CCSlidesPanel';
|
||||||
|
import { CCFilesPanel } from './CCFilesPanel';
|
||||||
|
import { CCCabinetsPanel } from './CCCabinetsPanel';
|
||||||
import { CCYoutubePanel } from './CCYoutubePanel';
|
import { CCYoutubePanel } from './CCYoutubePanel';
|
||||||
import { CCGraphPanel } from './CCGraphPanel';
|
import { CCGraphPanel } from './CCGraphPanel';
|
||||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
||||||
@ -40,8 +42,10 @@ import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
|||||||
|
|
||||||
export const PANEL_TYPES = {
|
export const PANEL_TYPES = {
|
||||||
default: [
|
default: [
|
||||||
|
{ id: 'cabinets', label: 'Cabinets', order: 5 },
|
||||||
{ id: 'navigation', label: 'Navigation', order: 10 },
|
{ id: 'navigation', label: 'Navigation', order: 10 },
|
||||||
{ id: 'node-snapshot', label: 'Node', order: 20 },
|
{ id: 'node-snapshot', label: 'Node', order: 20 },
|
||||||
|
{ id: 'files', label: 'Files', order: 25 },
|
||||||
{ id: 'cc-shapes', label: 'Shapes', order: 30 },
|
{ id: 'cc-shapes', label: 'Shapes', order: 30 },
|
||||||
{ id: 'slides', label: 'Slides', order: 40 },
|
{ id: 'slides', label: 'Slides', order: 40 },
|
||||||
{ id: 'youtube', label: 'YouTube', order: 50 },
|
{ id: 'youtube', label: 'YouTube', order: 50 },
|
||||||
@ -111,7 +115,7 @@ const StyledMenuItem = styled(MenuItem)(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const BasePanel: React.FC<BasePanelProps> = ({
|
export const BasePanel: React.FC<BasePanelProps> = ({
|
||||||
initialPanelType = 'cc-shapes',
|
initialPanelType = 'files',
|
||||||
examMarkerProps,
|
examMarkerProps,
|
||||||
isExpanded: controlledIsExpanded,
|
isExpanded: controlledIsExpanded,
|
||||||
isPinned: controlledIsPinned,
|
isPinned: controlledIsPinned,
|
||||||
@ -151,8 +155,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use controlled state if provided, otherwise use internal state
|
// Use controlled state if provided, otherwise use internal state
|
||||||
const [internalIsExpanded, setInternalIsExpanded] = React.useState(false);
|
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
|
||||||
const [internalIsPinned, setInternalIsPinned] = React.useState(false);
|
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
|
||||||
|
|
||||||
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
||||||
const isPinned = controlledIsPinned ?? internalIsPinned;
|
const isPinned = controlledIsPinned ?? internalIsPinned;
|
||||||
@ -200,6 +204,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
|
|
||||||
const getIconForPanel = (panelId: PanelType) => {
|
const getIconForPanel = (panelId: PanelType) => {
|
||||||
switch (panelId) {
|
switch (panelId) {
|
||||||
|
case 'cabinets':
|
||||||
|
return <NavigationIcon />;
|
||||||
case 'cc-shapes':
|
case 'cc-shapes':
|
||||||
return <ShapesIcon />;
|
return <ShapesIcon />;
|
||||||
case 'slides':
|
case 'slides':
|
||||||
@ -223,6 +229,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
|
|
||||||
const getDescriptionForPanel = (panelId: PanelType) => {
|
const getDescriptionForPanel = (panelId: PanelType) => {
|
||||||
switch (panelId) {
|
switch (panelId) {
|
||||||
|
case 'cabinets':
|
||||||
|
return 'Manage file cabinets';
|
||||||
case 'cc-shapes':
|
case 'cc-shapes':
|
||||||
return 'Add shapes and elements to your canvas';
|
return 'Add shapes and elements to your canvas';
|
||||||
case 'slides':
|
case 'slides':
|
||||||
@ -250,6 +258,10 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (currentPanelType) {
|
switch (currentPanelType) {
|
||||||
|
case 'cabinets':
|
||||||
|
return <CCCabinetsPanel />;
|
||||||
|
case 'files':
|
||||||
|
return <CCFilesPanel />;
|
||||||
case 'cc-shapes':
|
case 'cc-shapes':
|
||||||
return <CCShapesPanel />;
|
return <CCShapesPanel />;
|
||||||
case 'slides':
|
case 'slides':
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
863
src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx
Normal file
863
src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user