|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useDropzone } from 'react-dropzone';
|
|
import FlowingMenu from './components/FlowingMenu';
|
|
import FileUploader from './components/FileUploader';
|
|
import PreviewPanel from './components/PreviewPanel';
|
|
import ResultsPanel from './components/ResultsPanel';
|
|
import Orb from './components/Orb';
|
|
import TrueFocus from './components/TrueFocus';
|
|
import LoadingScreen from './components/LoadingScreen';
|
|
import ProcessingProgress from './components/ProcessingProgress';
|
|
import ApiKeyEncryption from './utils/encryption';
|
|
import './index.css';
|
|
|
|
function App() {
|
|
const [apiKey, setApiKey] = useState(() => {
|
|
|
|
return ApiKeyEncryption.retrieveApiKey() || '';
|
|
});
|
|
|
|
|
|
const handleApiKeyChange = (newApiKey) => {
|
|
setApiKey(newApiKey);
|
|
|
|
|
|
if (newApiKey.trim()) {
|
|
ApiKeyEncryption.storeApiKey(newApiKey);
|
|
console.log('🔐 API key encrypted and stored securely');
|
|
} else {
|
|
ApiKeyEncryption.clearApiKey();
|
|
console.log('🗑️ Encrypted API key cleared');
|
|
}
|
|
};
|
|
|
|
const [uploadedFile, setUploadedFile] = useState(null);
|
|
const [extractedText, setExtractedText] = useState('');
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0, status: '', fileName: '' });
|
|
const [processingMode, setProcessingMode] = useState('standard');
|
|
const [activeTab, setActiveTab] = useState('upload');
|
|
const [previewMode, setPreviewMode] = useState('text');
|
|
const [showLoading, setShowLoading] = useState(true);
|
|
const fileInputRef = useRef(null);
|
|
|
|
|
|
const [serverHasApiKey, setServerHasApiKey] = useState(false);
|
|
|
|
|
|
useEffect(() => {
|
|
const migrateOldApiKey = () => {
|
|
const oldApiKey = localStorage.getItem('gemini-api-key');
|
|
if (oldApiKey && !ApiKeyEncryption.retrieveApiKey()) {
|
|
console.log('🔄 Migrating old API key to encrypted storage...');
|
|
ApiKeyEncryption.storeApiKey(oldApiKey);
|
|
localStorage.removeItem('gemini-api-key');
|
|
setApiKey(oldApiKey);
|
|
console.log('✅ API key migration completed');
|
|
}
|
|
};
|
|
|
|
migrateOldApiKey();
|
|
}, []);
|
|
|
|
|
|
useEffect(() => {
|
|
const initializeApp = async () => {
|
|
try {
|
|
|
|
const healthResponse = await fetch('http://localhost:3002/api/health');
|
|
if (healthResponse.ok) {
|
|
const healthData = await healthResponse.json();
|
|
setServerHasApiKey(healthData.hasApiKey || false);
|
|
}
|
|
|
|
|
|
await fetch('http://localhost:3002/api/cleanup', { method: 'POST' });
|
|
console.log('✅ Cleanup completed on app load');
|
|
} catch (error) {
|
|
console.log('⚠️ App initialization failed (server might be starting):', error.message);
|
|
}
|
|
};
|
|
|
|
|
|
setTimeout(initializeApp, 2000);
|
|
}, []);
|
|
|
|
const menuItems = [
|
|
{ id: 'upload', label: 'Upload', icon: '↑' },
|
|
{ id: 'preview', label: 'Preview', icon: '○' },
|
|
{ id: 'results', label: 'RESULTS', icon: '↓' }
|
|
];
|
|
|
|
const onDrop = useCallback(async (acceptedFiles) => {
|
|
const file = acceptedFiles[0];
|
|
if (file) {
|
|
|
|
try {
|
|
await fetch('http://localhost:3002/api/cleanup', { method: 'POST' });
|
|
console.log('✅ Pre-upload cleanup completed');
|
|
} catch (error) {
|
|
console.log('⚠️ Pre-upload cleanup failed:', error.message);
|
|
}
|
|
|
|
setUploadedFile(file);
|
|
setActiveTab('preview');
|
|
}
|
|
}, []);
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept: {
|
|
'image/*': ['.png', '.jpg', '.jpeg'],
|
|
'application/pdf': ['.pdf'],
|
|
'text/html': ['.html', '.htm']
|
|
},
|
|
multiple: false
|
|
});
|
|
|
|
const handlePaste = useCallback((e) => {
|
|
const items = e.clipboardData?.items;
|
|
if (items) {
|
|
for (let item of items) {
|
|
if (item.type.indexOf('image') !== -1) {
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
setUploadedFile(file);
|
|
setActiveTab('preview');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const handleFileSelect = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
setUploadedFile(file);
|
|
setActiveTab('preview');
|
|
}
|
|
};
|
|
|
|
const processFile = async () => {
|
|
if (!uploadedFile || !apiKey) return;
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
setProcessingProgress({
|
|
current: 0,
|
|
total: 1,
|
|
status: '🔄 Initializing...',
|
|
fileName: uploadedFile.name
|
|
});
|
|
|
|
let progressInterval = null;
|
|
|
|
try {
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', uploadedFile);
|
|
formData.append('apiKey', apiKey);
|
|
formData.append('mode', processingMode);
|
|
|
|
console.log('Processing file:', uploadedFile.name, 'Mode:', processingMode);
|
|
|
|
|
|
const ocrPromise = fetch('http://localhost:3002/api/ocr', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
|
|
progressInterval = setInterval(async () => {
|
|
try {
|
|
|
|
|
|
const progressResponse = await fetch(`http://localhost:3002/api/progress-latest`);
|
|
if (progressResponse.ok) {
|
|
const progressData = await progressResponse.json();
|
|
|
|
if (progressData.current > 0) {
|
|
setProcessingProgress({
|
|
current: progressData.current,
|
|
total: progressData.total,
|
|
status: progressData.status,
|
|
fileName: progressData.fileName || uploadedFile.name,
|
|
totalPages: progressData.totalPages || 1,
|
|
currentPage: progressData.currentPage || 1,
|
|
totalCharacters: progressData.totalCharacters || 0,
|
|
pageCharacters: progressData.pageCharacters || 0,
|
|
phase: progressData.phase || 'processing',
|
|
consoleLogs: progressData.consoleLogs || []
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
|
|
console.log('Progress polling error (ignored):', error.message);
|
|
}
|
|
}, 1000);
|
|
|
|
|
|
const response = await ocrPromise;
|
|
const result = await response.json();
|
|
|
|
|
|
if (progressInterval) {
|
|
clearInterval(progressInterval);
|
|
progressInterval = null;
|
|
}
|
|
|
|
if (result.success) {
|
|
|
|
setProcessingProgress(prev => ({
|
|
...prev,
|
|
current: prev.total,
|
|
status: '✅ Processing complete!'
|
|
}));
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
|
|
setExtractedText(result.data.extractedText);
|
|
setActiveTab('results');
|
|
|
|
console.log('✅ OCR Success:', {
|
|
fileName: result.data.fileName,
|
|
characters: result.data.metadata.characterCount,
|
|
words: result.data.metadata.wordCount,
|
|
mode: result.data.processingMode
|
|
});
|
|
} else {
|
|
console.error('❌ OCR Error:', result.error);
|
|
alert(`OCR Error: ${result.error}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Network Error:', error);
|
|
|
|
|
|
if (progressInterval) {
|
|
clearInterval(progressInterval);
|
|
progressInterval = null;
|
|
}
|
|
|
|
|
|
if (error.message.includes('fetch')) {
|
|
alert(`Network Error: Cannot connect to OCR backend.
|
|
|
|
Please make sure:
|
|
1. Backend server is running on port 3002
|
|
2. Run: cd server && npm install && npm start
|
|
3. Check console for any backend errors
|
|
4. Visit http://localhost:3002 to verify backend is running
|
|
|
|
Error: ${error.message}`);
|
|
} else {
|
|
alert(`Processing Error: ${error.message}`);
|
|
}
|
|
} finally {
|
|
|
|
if (progressInterval) {
|
|
clearInterval(progressInterval);
|
|
}
|
|
setIsProcessing(false);
|
|
setProcessingProgress({ current: 0, total: 0, status: '', fileName: '' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="app" onPaste={handlePaste}>
|
|
{/* Loading Screen */}
|
|
{showLoading && (
|
|
<LoadingScreen onComplete={() => setShowLoading(false)} />
|
|
)}
|
|
|
|
{/* Giant Central Orb - Fades in after loading */}
|
|
<div className={`giant-orb-background ${!showLoading ? 'loaded' : ''}`}>
|
|
<div className="giant-orb-container">
|
|
<Orb hue={0} hoverIntensity={0.5} rotateOnHover={true} forceHoverState={false} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="glass-container">
|
|
<header className="app-header">
|
|
<motion.div
|
|
className="logo"
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.8 }}
|
|
>
|
|
<TrueFocus
|
|
sentence="Luna OCR"
|
|
manualMode={true}
|
|
blurAmount={8.5}
|
|
borderColor="#ffffff"
|
|
glowColor="rgba(255, 255, 255, 0.8)"
|
|
animationDuration={0.5}
|
|
pauseBetweenAnimations={2}
|
|
/>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="api-key-input"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.8, delay: 0.2 }}
|
|
>
|
|
{!serverHasApiKey && (
|
|
<div className="api-key-container">
|
|
<input
|
|
type="password"
|
|
placeholder={apiKey ? "API Key loaded from storage" : "Enter Google API Key..."}
|
|
value={apiKey}
|
|
onChange={(e) => handleApiKeyChange(e.target.value)}
|
|
className="glass-input"
|
|
/>
|
|
{apiKey && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleApiKeyChange('')}
|
|
className="clear-api-key-btn"
|
|
title="Clear saved API key"
|
|
>
|
|
×
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{serverHasApiKey && (
|
|
<div className="server-api-notice">
|
|
<p>🔒 API key configured on server - no user key required</p>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</header>
|
|
|
|
<FlowingMenu
|
|
items={menuItems}
|
|
activeItem={activeTab}
|
|
onItemClick={setActiveTab}
|
|
/>
|
|
|
|
<main className="app-main">
|
|
<AnimatePresence mode="wait">
|
|
{activeTab === 'upload' && (
|
|
<motion.div
|
|
key="upload"
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 20 }}
|
|
className="tab-content"
|
|
>
|
|
<FileUploader
|
|
getRootProps={getRootProps}
|
|
getInputProps={getInputProps}
|
|
isDragActive={isDragActive}
|
|
onFileSelect={handleFileSelect}
|
|
fileInputRef={fileInputRef}
|
|
onFileChange={handleFileChange}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
|
|
|
|
{activeTab === 'preview' && uploadedFile && (
|
|
<motion.div
|
|
key="preview"
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 20 }}
|
|
className="tab-content"
|
|
>
|
|
<PreviewPanel
|
|
file={uploadedFile}
|
|
processingMode={processingMode}
|
|
onModeChange={setProcessingMode}
|
|
onProcess={processFile}
|
|
isProcessing={isProcessing}
|
|
apiKey={apiKey}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{activeTab === 'results' && extractedText && (
|
|
<motion.div
|
|
key="results"
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 20 }}
|
|
className="tab-content"
|
|
>
|
|
<ResultsPanel
|
|
text={extractedText}
|
|
previewMode={previewMode}
|
|
onPreviewModeChange={setPreviewMode}
|
|
fileName={uploadedFile?.name}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</main>
|
|
</div>
|
|
|
|
{/* Processing Progress Overlay */}
|
|
<ProcessingProgress
|
|
progress={processingProgress}
|
|
isVisible={isProcessing}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App; |