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 { useAuth } from '../../../../../contexts/AuthContext'; 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 } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; const { user: authUser, accessToken } = useAuth(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [cabinets, setCabinets] = useState([]); const [selectedCabinet, setSelectedCabinet] = useState(''); const [files, setFiles] = useState([]); const [pagination, setPagination] = useState(null); const [loading, setLoading] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); const [artefacts, setArtefacts] = useState([]); // 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(null); const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([ { id: null, name: 'Root' } ]); const initialSelectionDone = useRef(false); // Directory upload state const [selectedFiles, setSelectedFiles] = useState([]); 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 } | undefined; type HeadersInitLike = Record; const API_BASE: string = import.meta.env.VITE_API_BASE || '/api'; const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => { const headers: HeadersInitLike = { 'Authorization': `Bearer ${accessToken || ''}`, ...(init?.headers || {}) }; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; const res = await fetch(fullUrl, { ...(init || {}), headers }); if (!res.ok) throw new Error(await res.text()); return res.json(); }, [accessToken, 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 && !initialSelectionDone.current) { initialSelectionDone.current = true; setSelectedCabinet(all[0].id); } } catch (error) { console.error('Failed to load cabinets:', error); } finally { setLoading(false); } }, [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(() => { if (authUser?.id) { initialSelectionDone.current = false; loadCabinets(); } }, [loadCabinets, authUser?.id]); // 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) => { 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) => { 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 ; if (!mime) return ; if (mime.startsWith('image/')) return ; if (mime === 'application/pdf' || mime.startsWith('application/')) return ; return ; }; 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 ( {/* Cabinet Selection Dropdown */} Cabinet {/* Search Box - Full Width */} setSearchTerm(e.target.value)} fullWidth placeholder="Type to search files..." /> {/* Sort and Filter Controls */} Sort Order Per page {/* Breadcrumb Navigation */} {breadcrumbs.map((breadcrumb, index) => ( {index > 0 && ( / )} ))} {/* File List with Fixed Height */} {loading ? ( Loading files... ) : sortedFiles.length === 0 ? ( {searchTerm ? 'No files found matching your search.' : 'No files found. Upload some files!'} ) : ( {sortedFiles.map((f, index) => ( {f.is_directory ? ( navigateToFolder(f)} sx={{ cursor: 'pointer', '&:hover': { backgroundColor: 'action.hover' } }} secondaryAction={ <> openMenu(e.currentTarget, f.id)} title="File actions"> handleDelete(f.id)} title="Delete file"> } > {iconForMime(f.mime_type, f.is_directory)} {f.name} {f.is_directory && } {f.processing_status && f.processing_status !== 'uploaded' && ( )} } secondary={ {f.size_bytes ? formatFileSize(f.size_bytes) : 'Unknown size'} {f.mime_type && ` • ${f.mime_type.split('/')[1]}`} } /> ) : ( openMenu(e.currentTarget, f.id)} title="File actions"> handleDelete(f.id)} title="Delete file"> } > {iconForMime(f.mime_type, f.is_directory)} {f.name} {f.is_directory && } {f.processing_status && f.processing_status !== 'uploaded' && ( )} } secondary={ {f.size_bytes ? formatFileSize(f.size_bytes) : 'Unknown size'} {f.mime_type && ` • ${f.mime_type.split('/')[1]}`} } /> )} {/* Group separator between directories and files */} {index === getGroupSeparatorIndex && needsGroupSeparator && ( )} {/* Regular divider between items */} {index < sortedFiles.length - 1 && index !== getGroupSeparatorIndex && } ))} )} {/* Pagination Controls */} {pagination && pagination.total_pages > 1 && ( setCurrentPage(value)} color="primary" size="small" showFirstButton showLastButton /> {pagination.offset + 1}-{Math.min(pagination.offset + pagination.per_page, pagination.total_count)} of {pagination.total_count} )} {/* Upload Controls */} {/* File Inputs */} )} multiple onChange={handleDirectorySelect} disabled={!selectedCabinet} /> {/* Upload Buttons */} {!selectedCabinet && ( Select a cabinet first to enable uploads )} {selectedCabinet && !isDirectoryPickerSupported() && ( ⚠️ Folder uploads may have limited support in this browser )} { if (menuAnchor) { handleGenerateInitial(menuAnchor.fileId); closeMenu(); } }}>Generate initial artefacts Open AI content {artefacts.length > 0 && ( <> {artefacts.map(a => ( ))} )} {/* Directory Upload Dialog */} !isDirectoryUploading && setShowDirectoryDialog(false)} maxWidth="md" fullWidth > Directory Upload {isDirectoryUploading && } {directoryStats && ( {directoryStats.fileCount} files in{' '} {directoryStats.directoryCount} folders
Total size: {directoryStats.formattedSize}
)} Files to upload: {selectedFiles.map((file, i) => ( {file.relativePath} {formatFileSize(file.size)} ))}
); };