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