vn6295337's picture
Add parsing evaluation UI to frontend
2eda359
import { useState, useEffect, useRef } from 'react';
import { exchangeDropboxCode, getDropboxFolder } from '../api/client';
const DROPBOX_APP_KEY = import.meta.env.VITE_DROPBOX_APP_KEY;
const REDIRECT_URI = window.location.origin;
// Supported file extensions (Docling supports many formats)
const SUPPORTED_EXTENSIONS = ['.txt', '.md', '.pdf', '.docx', '.pptx', '.xlsx', '.html', '.htm', '.jpg', '.jpeg', '.png', '.bmp', '.tiff'];
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
export default function CloudConnect({ onFilesStaged, stagedFiles = [], onAccessTokenChange }) {
const [isSignedIn, setIsSignedIn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [showPicker, setShowPicker] = useState(false);
const [files, setFiles] = useState([]);
const [folders, setFolders] = useState([]);
const [currentPath, setCurrentPath] = useState('');
const [pathStack, setPathStack] = useState([]);
const [pickerSelectedFiles, setPickerSelectedFiles] = useState([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const popupRef = useRef(null);
const popupCheckInterval = useRef(null);
// Listen for OAuth callback message from popup
useEffect(() => {
const handleMessage = async (event) => {
// Verify origin for security
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'DROPBOX_AUTH_CODE') {
const { code, error: authError } = event.data;
// Close popup
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
clearInterval(popupCheckInterval.current);
if (authError) {
setError(`Dropbox error: ${authError}`);
setIsLoading(false);
return;
}
if (code) {
try {
const data = await exchangeDropboxCode(code, REDIRECT_URI);
if (data.access_token) {
setAccessToken(data.access_token);
setIsSignedIn(true);
onAccessTokenChange?.(data.access_token);
} else {
setError(data.error || 'Failed to get access token');
}
} catch (err) {
setError(err.message);
}
setIsLoading(false);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onAccessTokenChange]);
// Check if this is the OAuth callback page (popup)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const errorParam = params.get('error');
// If we have code/error and we're in a popup, send message to parent
if ((code || errorParam) && window.opener) {
window.opener.postMessage({
type: 'DROPBOX_AUTH_CODE',
code: code,
error: errorParam
}, window.location.origin);
window.close();
}
}, []);
// Fetch folder contents
const fetchFolder = async (path) => {
setLoadingFiles(true);
setError(null);
try {
const data = await getDropboxFolder(path, accessToken);
if (data.error) {
setError(data.error);
setLoadingFiles(false);
return;
}
const entries = data.entries || [];
const folderItems = entries.filter(item => item['.tag'] === 'folder');
const fileItems = entries.filter(item => item['.tag'] === 'file');
setFolders(folderItems);
setFiles(fileItems);
} catch (err) {
setError(`Failed to load files: ${err.message}`);
}
setLoadingFiles(false);
};
// Check if file is supported
const isFileSupported = (file) => {
const name = file.name.toLowerCase();
return SUPPORTED_EXTENSIONS.some(ext => name.endsWith(ext));
};
// Check if file size is within limit
const isFileSizeOk = (file) => {
return file.size <= MAX_FILE_SIZE_BYTES;
};
// Open picker
const openPicker = () => {
setShowPicker(true);
setPickerSelectedFiles([]);
setCurrentPath('');
setPathStack([]);
fetchFolder('');
};
// Navigate to folder
const navigateToFolder = (folder) => {
setPathStack([...pathStack, { path: currentPath, name: currentPath || 'Dropbox' }]);
setCurrentPath(folder.path_lower);
fetchFolder(folder.path_lower);
};
// Go back
const goBack = () => {
if (pathStack.length > 0) {
const prev = pathStack[pathStack.length - 1];
setPathStack(pathStack.slice(0, -1));
setCurrentPath(prev.path);
fetchFolder(prev.path);
}
};
// Toggle file selection in picker
const toggleFile = (file) => {
if (!isFileSupported(file)) return;
if (!isFileSizeOk(file)) return;
if (pickerSelectedFiles.find(f => f.id === file.id)) {
setPickerSelectedFiles(pickerSelectedFiles.filter(f => f.id !== file.id));
} else {
setPickerSelectedFiles([...pickerSelectedFiles, file]);
}
};
// Confirm selection - adds to staged files
const confirmSelection = () => {
// Merge with existing staged files, avoiding duplicates
const existingIds = new Set(stagedFiles.map(f => f.id));
const newFiles = pickerSelectedFiles.filter(f => !existingIds.has(f.id));
const merged = [...stagedFiles, ...newFiles];
onFilesStaged?.(merged);
setShowPicker(false);
};
const handleConnect = () => {
if (!DROPBOX_APP_KEY) {
setError('Dropbox App Key not configured');
return;
}
setIsLoading(true);
setError(null);
const authUrl = new URL('https://www.dropbox.com/oauth2/authorize');
authUrl.searchParams.set('client_id', DROPBOX_APP_KEY);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('token_access_type', 'offline');
// Open popup window for OAuth
const width = 500;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
popupRef.current = window.open(
authUrl.toString(),
'dropbox-auth',
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=yes`
);
// Check if popup was blocked
if (!popupRef.current || popupRef.current.closed) {
setError('Popup blocked. Please allow popups for this site.');
setIsLoading(false);
return;
}
// Monitor popup - if user closes it manually
popupCheckInterval.current = setInterval(() => {
if (popupRef.current && popupRef.current.closed) {
clearInterval(popupCheckInterval.current);
setIsLoading(false);
}
}, 500);
};
const handleDisconnect = () => {
setAccessToken(null);
setIsSignedIn(false);
setShowPicker(false);
onAccessTokenChange?.(null);
onFilesStaged?.([]);
};
// Get display name for current path
const getCurrentFolderName = () => {
if (!currentPath) return 'Dropbox';
const parts = currentPath.split('/');
return parts[parts.length - 1] || 'Dropbox';
};
// Format file size
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
if (!DROPBOX_APP_KEY) {
return (
<div className="bg-yellow-900/30 border border-yellow-700 rounded-lg p-3 text-sm text-yellow-400" role="alert">
<p className="font-medium">Dropbox not configured</p>
<p className="text-xs mt-1 text-yellow-500">Set VITE_DROPBOX_APP_KEY in environment</p>
</div>
);
}
return (
<div className="space-y-3">
{error && (
<div className="bg-red-900/30 border border-red-700 rounded-lg p-3 text-sm text-red-400" role="alert">
{error}
</div>
)}
{!isSignedIn ? (
<div className="space-y-3">
<button
type="button"
onClick={handleConnect}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 bg-slate-700 border border-slate-600 rounded-lg px-4 py-2.5 text-sm font-medium text-slate-200 hover:bg-slate-600 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-all duration-200"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-slate-400 border-t-transparent rounded-full animate-spin"></div>
) : (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="#0061FF">
<path d="M12 6.5l-6 3.75L12 14l6-3.75L12 6.5zM6 14l6 3.75L18 14l-6 3.75L6 14zM6 10.25L0 6.5l6-3.75 6 3.75-6 3.75zM18 10.25l6-3.75-6-3.75-6 3.75 6 3.75z"/>
</svg>
)}
Connect Dropbox
</button>
<p className="text-xs text-slate-500 text-center">
PDF, DOCX, PPTX, XLSX, HTML, images (max {MAX_FILE_SIZE_MB} MB)
</p>
</div>
) : (
<div className="space-y-3">
{/* Connection status */}
<div className="flex items-center justify-between text-sm bg-green-900/30 border border-green-700 rounded-lg px-3 py-2">
<div className="flex items-center gap-2 text-green-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Connected to Dropbox</span>
</div>
<button
type="button"
onClick={handleDisconnect}
className="text-xs text-green-400 hover:text-green-300 font-medium"
aria-label="Disconnect from Dropbox"
>
Disconnect
</button>
</div>
{/* Select files button */}
<button
type="button"
onClick={openPicker}
className="w-full flex items-center justify-center gap-2 bg-slate-700 border border-slate-600 text-slate-200 rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-slate-600 active:scale-[0.98] shadow-sm transition-all duration-200"
>
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{stagedFiles.length > 0 ? 'Add More Files' : 'Select Files'}
</button>
{/* File type hints */}
<p className="text-xs text-slate-500 text-center">
PDF, DOCX, PPTX, XLSX, HTML, images (max {MAX_FILE_SIZE_MB} MB)
</p>
</div>
)}
{/* File Picker Modal */}
{showPicker && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col shadow-xl">
{/* Header */}
<div className="p-4 border-b border-slate-700 flex items-center justify-between">
<div className="flex items-center gap-2">
{pathStack.length > 0 && (
<button
type="button"
onClick={goBack}
className="p-1.5 hover:bg-slate-700 rounded-lg transition-colors"
aria-label="Go back"
>
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
<h3 className="font-medium text-slate-100">{getCurrentFolderName()}</h3>
</div>
<button
type="button"
onClick={() => setShowPicker(false)}
className="p-1.5 hover:bg-slate-700 rounded-lg transition-colors"
aria-label="Close picker"
>
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* File list */}
<div className="flex-1 overflow-auto p-3">
{loadingFiles ? (
<div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
</div>
) : (
<div className="space-y-1">
{folders.map(folder => (
<div
key={folder.id}
onClick={() => navigateToFolder(folder)}
className="flex items-center gap-3 p-2.5 hover:bg-slate-700 rounded-lg cursor-pointer transition-colors"
>
<svg className="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
</svg>
<span className="text-sm text-slate-200">{folder.name}</span>
</div>
))}
{files.map(file => {
const supported = isFileSupported(file);
const sizeOk = isFileSizeOk(file);
const selectable = supported && sizeOk;
const isSelected = pickerSelectedFiles.find(f => f.id === file.id);
return (
<div
key={file.id}
onClick={() => selectable && toggleFile(file)}
className={`flex items-center gap-3 p-2.5 rounded-lg transition-colors ${
!selectable
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-blue-900/40 border border-blue-700 cursor-pointer'
: 'hover:bg-slate-700 cursor-pointer'
}`}
>
<input
type="checkbox"
checked={!!isSelected}
disabled={!selectable}
onChange={() => {}}
className="w-4 h-4 rounded border-slate-600 bg-slate-700 text-blue-500 focus:ring-blue-500"
/>
<svg className="w-5 h-5 text-slate-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4z"/>
</svg>
<div className="flex-1 min-w-0">
<span className="text-sm text-slate-200 truncate block">{file.name}</span>
<span className="text-xs text-slate-500">
{formatSize(file.size)}
{!supported && ' - Unsupported format'}
{supported && !sizeOk && ' - File too large'}
</span>
</div>
</div>
);
})}
{folders.length === 0 && files.length === 0 && (
<div className="text-center py-12">
<svg className="w-12 h-12 text-slate-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<p className="text-slate-500 text-sm">This folder is empty</p>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-700 flex items-center justify-between bg-slate-800/50 rounded-b-xl">
<span className="text-sm text-slate-400">
{pickerSelectedFiles.length} file(s) selected
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowPicker(false)}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={confirmSelection}
disabled={pickerSelectedFiles.length === 0}
className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
Add Selected
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}