feat: migrate App.tsx logic to WorkspacePage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
573
src/App.tsx
573
src/App.tsx
@@ -1,572 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
|
||||||
import { useLanguage } from './contexts/LanguageContext';
|
|
||||||
import { uploadService } from './lib/uploadService';
|
|
||||||
import { FileRecord, RecognitionResult } from './types';
|
|
||||||
import { TaskStatus, TaskHistoryItem } from './types/api';
|
|
||||||
import LeftSidebar from './components/LeftSidebar';
|
|
||||||
import Navbar from './components/Navbar';
|
|
||||||
import FilePreview from './components/FilePreview';
|
|
||||||
import ResultPanel from './components/ResultPanel';
|
|
||||||
import UploadModal from './components/UploadModal';
|
|
||||||
import UserGuide from './components/UserGuide';
|
|
||||||
import AuthModal from './components/AuthModal';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 6;
|
export default function App() {
|
||||||
const GUEST_USAGE_LIMIT = 3;
|
return <Navigate to="/" replace />;
|
||||||
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const { user, initializing } = useAuth();
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const [files, setFiles] = useState<FileRecord[]>([]);
|
|
||||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
|
||||||
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
|
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
|
||||||
const [showUserGuide, setShowUserGuide] = useState(false);
|
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [guestUsageCount, setGuestUsageCount] = useState<number>(() => {
|
|
||||||
const storedCount = localStorage.getItem(GUEST_USAGE_COUNT_KEY);
|
|
||||||
const parsedCount = storedCount ? Number.parseInt(storedCount, 10) : 0;
|
|
||||||
return Number.isFinite(parsedCount) ? parsedCount : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [hasMore, setHasMore] = useState(false);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
|
|
||||||
// Layout state
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(320);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Polling intervals refs to clear them on unmount
|
|
||||||
const pollingIntervals = useRef<Record<string, NodeJS.Timeout>>({});
|
|
||||||
// Ref to track latest selectedFileId for use in polling callbacks (avoids stale closure)
|
|
||||||
const selectedFileIdRef = useRef<string | null>(null);
|
|
||||||
// Cache for recognition results (keyed by task_id/file_id)
|
|
||||||
const resultsCache = useRef<Record<string, RecognitionResult>>({});
|
|
||||||
// Ref to prevent double loading on mount (React 18 Strict Mode / dependency changes)
|
|
||||||
const hasLoadedFiles = useRef(false);
|
|
||||||
|
|
||||||
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
|
|
||||||
const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT;
|
|
||||||
|
|
||||||
const openAuthModal = useCallback(() => {
|
|
||||||
setShowAuthModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const incrementGuestUsage = useCallback(() => {
|
|
||||||
setGuestUsageCount((prev) => {
|
|
||||||
const nextCount = prev + 1;
|
|
||||||
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount));
|
|
||||||
return nextCount;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStartGuide = () => setShowUserGuide(true);
|
|
||||||
window.addEventListener('start-user-guide', handleStartGuide);
|
|
||||||
|
|
||||||
// Check for first-time user
|
|
||||||
const hasSeenGuide = localStorage.getItem('hasSeenGuide');
|
|
||||||
if (!hasSeenGuide) {
|
|
||||||
setTimeout(() => setShowUserGuide(true), 1500);
|
|
||||||
localStorage.setItem('hasSeenGuide', 'true');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => window.removeEventListener('start-user-guide', handleStartGuide);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initializing && user && !hasLoadedFiles.current) {
|
|
||||||
hasLoadedFiles.current = true;
|
|
||||||
loadFiles();
|
|
||||||
}
|
|
||||||
// Reset when user logs out
|
|
||||||
if (!user) {
|
|
||||||
hasLoadedFiles.current = false;
|
|
||||||
setFiles([]);
|
|
||||||
setSelectedFileId(null);
|
|
||||||
setCurrentPage(1);
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
}, [initializing, user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Keep ref in sync with state for polling callbacks
|
|
||||||
selectedFileIdRef.current = selectedFileId;
|
|
||||||
|
|
||||||
if (selectedFileId) {
|
|
||||||
loadResult(selectedFileId);
|
|
||||||
} else {
|
|
||||||
setSelectedResult(null);
|
|
||||||
}
|
|
||||||
}, [selectedFileId]);
|
|
||||||
|
|
||||||
// Cleanup polling on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
Object.values(pollingIntervals.current).forEach(clearInterval);
|
|
||||||
pollingIntervals.current = {};
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
|
||||||
// If modal is open, let the modal handle paste events to avoid double upload
|
|
||||||
if (showUploadModal) return;
|
|
||||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
|
||||||
openAuthModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
const files: File[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
if (items[i].type.startsWith('image/') || items[i].type === 'application/pdf') {
|
|
||||||
const file = items[i].getAsFile();
|
|
||||||
if (file) files.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length > 0) {
|
|
||||||
handleUpload(files);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
|
||||||
return () => document.removeEventListener('paste', handlePaste);
|
|
||||||
}, [guestUsageCount, openAuthModal, showUploadModal, user]);
|
|
||||||
|
|
||||||
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
|
|
||||||
mouseDownEvent.preventDefault();
|
|
||||||
setIsResizing(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopResizing = useCallback(() => {
|
|
||||||
setIsResizing(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resize = useCallback(
|
|
||||||
(mouseMoveEvent: MouseEvent) => {
|
|
||||||
if (isResizing) {
|
|
||||||
const newWidth = mouseMoveEvent.clientX;
|
|
||||||
if (newWidth >= 280 && newWidth <= 400) {
|
|
||||||
setSidebarWidth(newWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isResizing]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('mousemove', resize);
|
|
||||||
window.addEventListener('mouseup', stopResizing);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('mousemove', resize);
|
|
||||||
window.removeEventListener('mouseup', stopResizing);
|
|
||||||
};
|
|
||||||
}, [resize, stopResizing]);
|
|
||||||
|
|
||||||
// Convert API TaskHistoryItem to internal FileRecord format
|
|
||||||
const convertToFileRecord = (item: TaskHistoryItem): FileRecord => {
|
|
||||||
// Map numeric status enum to internal string status
|
|
||||||
const statusMap: Record<number, FileRecord['status']> = {
|
|
||||||
[TaskStatus.Pending]: 'pending',
|
|
||||||
[TaskStatus.Processing]: 'processing',
|
|
||||||
[TaskStatus.Completed]: 'completed',
|
|
||||||
[TaskStatus.Failed]: 'failed',
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.task_id,
|
|
||||||
user_id: user?.id || null,
|
|
||||||
filename: item.file_name,
|
|
||||||
file_path: item.origin_url, // Already signed OSS URL for preview
|
|
||||||
file_type: 'image/jpeg', // Default, could be derived from file_name
|
|
||||||
file_size: 0, // Not provided by API
|
|
||||||
thumbnail_path: null,
|
|
||||||
status: statusMap[item.status] || 'pending',
|
|
||||||
created_at: item.created_at,
|
|
||||||
updated_at: item.created_at,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert API TaskHistoryItem to internal RecognitionResult format
|
|
||||||
const convertToRecognitionResult = (item: TaskHistoryItem): RecognitionResult => {
|
|
||||||
return {
|
|
||||||
id: item.task_id,
|
|
||||||
file_id: item.task_id,
|
|
||||||
markdown_content: item.markdown,
|
|
||||||
latex_content: item.latex,
|
|
||||||
mathml_content: item.mathml,
|
|
||||||
mml: item.mml,
|
|
||||||
rendered_image_path: item.image_blob || null,
|
|
||||||
created_at: item.created_at,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadFiles = async () => {
|
|
||||||
// Don't fetch if user is not logged in
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// Fetch first page only
|
|
||||||
const data = await uploadService.getTaskList('FORMULA', 1, PAGE_SIZE);
|
|
||||||
const taskList = data.task_list || [];
|
|
||||||
const total = data.total || 0;
|
|
||||||
|
|
||||||
// Update pagination state
|
|
||||||
setCurrentPage(1);
|
|
||||||
setHasMore(taskList.length < total);
|
|
||||||
|
|
||||||
if (taskList.length > 0) {
|
|
||||||
// Convert API data to internal format
|
|
||||||
const fileRecords = taskList.map(convertToFileRecord);
|
|
||||||
setFiles(fileRecords);
|
|
||||||
|
|
||||||
// Cache all results from the history (they already contain full data)
|
|
||||||
taskList.forEach(item => {
|
|
||||||
if (item.status === TaskStatus.Completed) {
|
|
||||||
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
setFiles([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading files:', error);
|
|
||||||
setFiles([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMoreFiles = async () => {
|
|
||||||
// Don't fetch if user is not logged in or already loading
|
|
||||||
if (!user || loadingMore || !hasMore) return;
|
|
||||||
|
|
||||||
setLoadingMore(true);
|
|
||||||
try {
|
|
||||||
const nextPage = currentPage + 1;
|
|
||||||
const data = await uploadService.getTaskList('FORMULA', nextPage, PAGE_SIZE);
|
|
||||||
const taskList = data.task_list || [];
|
|
||||||
const total = data.total || 0;
|
|
||||||
|
|
||||||
if (taskList.length > 0) {
|
|
||||||
const newFileRecords = taskList.map(convertToFileRecord);
|
|
||||||
|
|
||||||
// Use functional update to get correct total count
|
|
||||||
setFiles(prev => {
|
|
||||||
const newFiles = [...prev, ...newFileRecords];
|
|
||||||
// Check if there are more pages based on new total
|
|
||||||
setHasMore(newFiles.length < total);
|
|
||||||
return newFiles;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache results
|
|
||||||
taskList.forEach(item => {
|
|
||||||
if (item.status === TaskStatus.Completed) {
|
|
||||||
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentPage(nextPage);
|
|
||||||
} else {
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading more files:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadResult = async (fileId: string) => {
|
|
||||||
try {
|
|
||||||
// First check the local cache (populated from history API)
|
|
||||||
const cachedResult = resultsCache.current[fileId];
|
|
||||||
if (cachedResult) {
|
|
||||||
setSelectedResult(cachedResult);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not in cache, try to fetch from API (for tasks still processing)
|
|
||||||
try {
|
|
||||||
const result = await uploadService.getTaskResult(fileId);
|
|
||||||
if (result.status === TaskStatus.Completed) {
|
|
||||||
const recognitionResult: RecognitionResult = {
|
|
||||||
id: fileId,
|
|
||||||
file_id: fileId,
|
|
||||||
markdown_content: result.markdown,
|
|
||||||
latex_content: result.latex,
|
|
||||||
mathml_content: result.mathml,
|
|
||||||
mml: result.mml,
|
|
||||||
rendered_image_path: result.image_blob || null,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
resultsCache.current[fileId] = recognitionResult;
|
|
||||||
setSelectedResult(recognitionResult);
|
|
||||||
} else {
|
|
||||||
setSelectedResult(null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Task might not exist or still processing
|
|
||||||
setSelectedResult(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading result:', error);
|
|
||||||
setSelectedResult(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startPolling = (taskNo: string, fileId: string) => {
|
|
||||||
if (pollingIntervals.current[taskNo]) return;
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 30;
|
|
||||||
|
|
||||||
pollingIntervals.current[taskNo] = setInterval(async () => {
|
|
||||||
attempts++;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await uploadService.getTaskResult(taskNo);
|
|
||||||
|
|
||||||
// Update status in file list
|
|
||||||
setFiles(prevFiles => prevFiles.map(f => {
|
|
||||||
if (f.id === fileId) {
|
|
||||||
let status: FileRecord['status'] = 'processing';
|
|
||||||
if (result.status === TaskStatus.Completed) status = 'completed';
|
|
||||||
else if (result.status === TaskStatus.Failed) status = 'failed';
|
|
||||||
|
|
||||||
return { ...f, status };
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (result.status === TaskStatus.Completed || result.status === TaskStatus.Failed) {
|
|
||||||
// Stop polling
|
|
||||||
clearInterval(pollingIntervals.current[taskNo]);
|
|
||||||
delete pollingIntervals.current[taskNo];
|
|
||||||
|
|
||||||
if (result.status === TaskStatus.Completed) {
|
|
||||||
// ... (keep existing completion logic)
|
|
||||||
// Convert API result to internal RecognitionResult format
|
|
||||||
const recognitionResult: RecognitionResult = {
|
|
||||||
id: fileId,
|
|
||||||
file_id: fileId,
|
|
||||||
markdown_content: result.markdown,
|
|
||||||
latex_content: result.latex,
|
|
||||||
mathml_content: result.mathml,
|
|
||||||
mml: result.mml,
|
|
||||||
rendered_image_path: result.image_blob || null,
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the result for later retrieval
|
|
||||||
resultsCache.current[fileId] = recognitionResult;
|
|
||||||
|
|
||||||
// Update UI if this file is currently selected
|
|
||||||
if (selectedFileIdRef.current === fileId) {
|
|
||||||
setSelectedResult(recognitionResult);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (attempts >= maxAttempts) {
|
|
||||||
// Timeout: Max attempts reached
|
|
||||||
clearInterval(pollingIntervals.current[taskNo]);
|
|
||||||
delete pollingIntervals.current[taskNo];
|
|
||||||
|
|
||||||
// Mark as failed in UI
|
|
||||||
setFiles(prevFiles => prevFiles.map(f => {
|
|
||||||
if (f.id === fileId) {
|
|
||||||
return { ...f, status: 'failed' };
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}));
|
|
||||||
|
|
||||||
alert(t.alerts.taskTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Polling error:', error);
|
|
||||||
// Don't stop polling immediately on network error, but maybe counting errors?
|
|
||||||
// For simplicity, keep polling until max attempts.
|
|
||||||
if (attempts >= maxAttempts) {
|
|
||||||
clearInterval(pollingIntervals.current[taskNo]);
|
|
||||||
delete pollingIntervals.current[taskNo];
|
|
||||||
setFiles(prevFiles => prevFiles.map(f => {
|
|
||||||
if (f.id === fileId) return { ...f, status: 'failed' };
|
|
||||||
return f;
|
|
||||||
}));
|
|
||||||
alert(t.alerts.networkError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2000); // Poll every 2 seconds
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = async (uploadFiles: File[]) => {
|
|
||||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
|
||||||
openAuthModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
let successfulUploads = 0;
|
|
||||||
|
|
||||||
for (const file of uploadFiles) {
|
|
||||||
// 1. Upload file to OSS (or check duplicate)
|
|
||||||
const fileHash = await uploadService.calculateMD5(file);
|
|
||||||
const signatureData = await uploadService.uploadFile(file);
|
|
||||||
|
|
||||||
// 2. Create recognition task
|
|
||||||
const taskData = await uploadService.createRecognitionTask(
|
|
||||||
signatureData.path,
|
|
||||||
fileHash,
|
|
||||||
file.name
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use task_no if available, or file ID from path, or fallback
|
|
||||||
const fileId = taskData.task_no || crypto.randomUUID();
|
|
||||||
|
|
||||||
const newFile: FileRecord = {
|
|
||||||
id: fileId,
|
|
||||||
user_id: user?.id || null,
|
|
||||||
filename: file.name,
|
|
||||||
// Use local object URL for immediate preview since OSS URL requires signing
|
|
||||||
file_path: URL.createObjectURL(file),
|
|
||||||
file_type: file.type,
|
|
||||||
file_size: file.size,
|
|
||||||
thumbnail_path: null,
|
|
||||||
status: 'processing',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add new file to the list (prepend)
|
|
||||||
setFiles(prevFiles => [newFile, ...prevFiles]);
|
|
||||||
setSelectedFileId(newFile.id);
|
|
||||||
|
|
||||||
// Start polling for this task
|
|
||||||
if (taskData.task_no) {
|
|
||||||
startPolling(taskData.task_no, fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
successfulUploads += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user && successfulUploads > 0) {
|
|
||||||
incrementGuestUsage();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading files:', error);
|
|
||||||
alert(`${t.alerts.uploadFailed}: ` + (error instanceof Error ? error.message : 'Unknown error'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (initializing) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">{t.common.loading}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex flex-col bg-gray-50 font-sans text-gray-900 overflow-hidden">
|
|
||||||
<Navbar />
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
|
||||||
{/* Left Sidebar */}
|
|
||||||
<div
|
|
||||||
ref={sidebarRef}
|
|
||||||
className="flex-shrink-0 bg-white border-r border-gray-200 relative transition-all duration-300 ease-in-out"
|
|
||||||
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
|
|
||||||
>
|
|
||||||
<LeftSidebar
|
|
||||||
files={files}
|
|
||||||
selectedFileId={selectedFileId}
|
|
||||||
onFileSelect={setSelectedFileId}
|
|
||||||
onUploadClick={() => {
|
|
||||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
|
||||||
openAuthModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowUploadModal(true);
|
|
||||||
}}
|
|
||||||
canUploadAnonymously={canUploadAnonymously}
|
|
||||||
onRequireAuth={openAuthModal}
|
|
||||||
isCollapsed={sidebarCollapsed}
|
|
||||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
||||||
onUploadFiles={handleUpload}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadingMore={loadingMore}
|
|
||||||
onLoadMore={loadMoreFiles}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Resize Handle */}
|
|
||||||
{!sidebarCollapsed && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-0 w-1 h-full cursor-col-resize hover:bg-blue-400 z-50 opacity-0 hover:opacity-100 transition-opacity"
|
|
||||||
onMouseDown={startResizing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Middle Content: File Preview */}
|
|
||||||
<div className="flex-1 flex min-w-0 flex-col bg-gray-100/50">
|
|
||||||
<FilePreview file={selectedFile} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Result: Recognition Result */}
|
|
||||||
<div className="flex-1 flex min-w-0 flex-col bg-white">
|
|
||||||
<ResultPanel
|
|
||||||
result={selectedResult}
|
|
||||||
fileStatus={selectedFile?.status}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showUploadModal && (
|
|
||||||
<UploadModal
|
|
||||||
onClose={() => setShowUploadModal(false)}
|
|
||||||
onUpload={handleUpload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAuthModal && (
|
|
||||||
<AuthModal
|
|
||||||
onClose={() => setShowAuthModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<UserGuide
|
|
||||||
isOpen={showUserGuide}
|
|
||||||
onClose={() => setShowUserGuide(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-xl shadow-xl p-8">
|
|
||||||
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-900 font-medium">{t.common.processing}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|||||||
526
src/pages/WorkspacePage.tsx
Normal file
526
src/pages/WorkspacePage.tsx
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { uploadService } from '../lib/uploadService';
|
||||||
|
import { FileRecord, RecognitionResult } from '../types';
|
||||||
|
import { TaskStatus, TaskHistoryItem } from '../types/api';
|
||||||
|
import LeftSidebar from '../components/LeftSidebar';
|
||||||
|
import FilePreview from '../components/FilePreview';
|
||||||
|
import ResultPanel from '../components/ResultPanel';
|
||||||
|
import UploadModal from '../components/UploadModal';
|
||||||
|
import UserGuide from '../components/UserGuide';
|
||||||
|
import AuthModal from '../components/AuthModal';
|
||||||
|
import SEOHead from '../components/seo/SEOHead';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 6;
|
||||||
|
const GUEST_USAGE_LIMIT = 3;
|
||||||
|
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
|
||||||
|
|
||||||
|
export default function WorkspacePage() {
|
||||||
|
const { user, initializing } = useAuth();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [files, setFiles] = useState<FileRecord[]>([]);
|
||||||
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||||
|
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [showUserGuide, setShowUserGuide] = useState(false);
|
||||||
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [guestUsageCount, setGuestUsageCount] = useState<number>(() => {
|
||||||
|
const storedCount = localStorage.getItem(GUEST_USAGE_COUNT_KEY);
|
||||||
|
const parsedCount = storedCount ? Number.parseInt(storedCount, 10) : 0;
|
||||||
|
return Number.isFinite(parsedCount) ? parsedCount : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(320);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const pollingIntervals = useRef<Record<string, NodeJS.Timeout>>({});
|
||||||
|
const selectedFileIdRef = useRef<string | null>(null);
|
||||||
|
const resultsCache = useRef<Record<string, RecognitionResult>>({});
|
||||||
|
const hasLoadedFiles = useRef(false);
|
||||||
|
|
||||||
|
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
|
||||||
|
const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT;
|
||||||
|
|
||||||
|
const openAuthModal = useCallback(() => {
|
||||||
|
setShowAuthModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const incrementGuestUsage = useCallback(() => {
|
||||||
|
setGuestUsageCount((prev) => {
|
||||||
|
const nextCount = prev + 1;
|
||||||
|
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount));
|
||||||
|
return nextCount;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStartGuide = () => setShowUserGuide(true);
|
||||||
|
window.addEventListener('start-user-guide', handleStartGuide);
|
||||||
|
|
||||||
|
const hasSeenGuide = localStorage.getItem('hasSeenGuide');
|
||||||
|
if (!hasSeenGuide) {
|
||||||
|
setTimeout(() => setShowUserGuide(true), 1500);
|
||||||
|
localStorage.setItem('hasSeenGuide', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => window.removeEventListener('start-user-guide', handleStartGuide);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initializing && user && !hasLoadedFiles.current) {
|
||||||
|
hasLoadedFiles.current = true;
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
hasLoadedFiles.current = false;
|
||||||
|
setFiles([]);
|
||||||
|
setSelectedFileId(null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
}, [initializing, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedFileIdRef.current = selectedFileId;
|
||||||
|
if (selectedFileId) {
|
||||||
|
loadResult(selectedFileId);
|
||||||
|
} else {
|
||||||
|
setSelectedResult(null);
|
||||||
|
}
|
||||||
|
}, [selectedFileId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(pollingIntervals.current).forEach(clearInterval);
|
||||||
|
pollingIntervals.current = {};
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
if (showUploadModal) return;
|
||||||
|
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
||||||
|
openAuthModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const files: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/') || items[i].type === 'application/pdf') {
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleUpload(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
return () => document.removeEventListener('paste', handlePaste);
|
||||||
|
}, [guestUsageCount, openAuthModal, showUploadModal, user]);
|
||||||
|
|
||||||
|
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
|
||||||
|
mouseDownEvent.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopResizing = useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = useCallback(
|
||||||
|
(mouseMoveEvent: MouseEvent) => {
|
||||||
|
if (isResizing) {
|
||||||
|
const newWidth = mouseMoveEvent.clientX;
|
||||||
|
if (newWidth >= 280 && newWidth <= 400) {
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isResizing]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('mousemove', resize);
|
||||||
|
window.addEventListener('mouseup', stopResizing);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', resize);
|
||||||
|
window.removeEventListener('mouseup', stopResizing);
|
||||||
|
};
|
||||||
|
}, [resize, stopResizing]);
|
||||||
|
|
||||||
|
const convertToFileRecord = (item: TaskHistoryItem): FileRecord => {
|
||||||
|
const statusMap: Record<number, FileRecord['status']> = {
|
||||||
|
[TaskStatus.Pending]: 'pending',
|
||||||
|
[TaskStatus.Processing]: 'processing',
|
||||||
|
[TaskStatus.Completed]: 'completed',
|
||||||
|
[TaskStatus.Failed]: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.task_id,
|
||||||
|
user_id: user?.id || null,
|
||||||
|
filename: item.file_name,
|
||||||
|
file_path: item.origin_url,
|
||||||
|
file_type: 'image/jpeg',
|
||||||
|
file_size: 0,
|
||||||
|
thumbnail_path: null,
|
||||||
|
status: statusMap[item.status] || 'pending',
|
||||||
|
created_at: item.created_at,
|
||||||
|
updated_at: item.created_at,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertToRecognitionResult = (item: TaskHistoryItem): RecognitionResult => {
|
||||||
|
return {
|
||||||
|
id: item.task_id,
|
||||||
|
file_id: item.task_id,
|
||||||
|
markdown_content: item.markdown,
|
||||||
|
latex_content: item.latex,
|
||||||
|
mathml_content: item.mathml,
|
||||||
|
mml: item.mml,
|
||||||
|
rendered_image_path: item.image_blob || null,
|
||||||
|
created_at: item.created_at,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await uploadService.getTaskList('FORMULA', 1, PAGE_SIZE);
|
||||||
|
const taskList = data.task_list || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
setCurrentPage(1);
|
||||||
|
setHasMore(taskList.length < total);
|
||||||
|
|
||||||
|
if (taskList.length > 0) {
|
||||||
|
const fileRecords = taskList.map(convertToFileRecord);
|
||||||
|
setFiles(fileRecords);
|
||||||
|
|
||||||
|
taskList.forEach(item => {
|
||||||
|
if (item.status === TaskStatus.Completed) {
|
||||||
|
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFiles([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading files:', error);
|
||||||
|
setFiles([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreFiles = async () => {
|
||||||
|
if (!user || loadingMore || !hasMore) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
const data = await uploadService.getTaskList('FORMULA', nextPage, PAGE_SIZE);
|
||||||
|
const taskList = data.task_list || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
if (taskList.length > 0) {
|
||||||
|
const newFileRecords = taskList.map(convertToFileRecord);
|
||||||
|
|
||||||
|
setFiles(prev => {
|
||||||
|
const newFiles = [...prev, ...newFileRecords];
|
||||||
|
setHasMore(newFiles.length < total);
|
||||||
|
return newFiles;
|
||||||
|
});
|
||||||
|
|
||||||
|
taskList.forEach(item => {
|
||||||
|
if (item.status === TaskStatus.Completed) {
|
||||||
|
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentPage(nextPage);
|
||||||
|
} else {
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading more files:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadResult = async (fileId: string) => {
|
||||||
|
try {
|
||||||
|
const cachedResult = resultsCache.current[fileId];
|
||||||
|
if (cachedResult) {
|
||||||
|
setSelectedResult(cachedResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadService.getTaskResult(fileId);
|
||||||
|
if (result.status === TaskStatus.Completed) {
|
||||||
|
const recognitionResult: RecognitionResult = {
|
||||||
|
id: fileId,
|
||||||
|
file_id: fileId,
|
||||||
|
markdown_content: result.markdown,
|
||||||
|
latex_content: result.latex,
|
||||||
|
mathml_content: result.mathml,
|
||||||
|
mml: result.mml,
|
||||||
|
rendered_image_path: result.image_blob || null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
resultsCache.current[fileId] = recognitionResult;
|
||||||
|
setSelectedResult(recognitionResult);
|
||||||
|
} else {
|
||||||
|
setSelectedResult(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSelectedResult(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading result:', error);
|
||||||
|
setSelectedResult(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = (taskNo: string, fileId: string) => {
|
||||||
|
if (pollingIntervals.current[taskNo]) return;
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30;
|
||||||
|
|
||||||
|
pollingIntervals.current[taskNo] = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadService.getTaskResult(taskNo);
|
||||||
|
|
||||||
|
setFiles(prevFiles => prevFiles.map(f => {
|
||||||
|
if (f.id === fileId) {
|
||||||
|
let status: FileRecord['status'] = 'processing';
|
||||||
|
if (result.status === TaskStatus.Completed) status = 'completed';
|
||||||
|
else if (result.status === TaskStatus.Failed) status = 'failed';
|
||||||
|
return { ...f, status };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (result.status === TaskStatus.Completed || result.status === TaskStatus.Failed) {
|
||||||
|
clearInterval(pollingIntervals.current[taskNo]);
|
||||||
|
delete pollingIntervals.current[taskNo];
|
||||||
|
|
||||||
|
if (result.status === TaskStatus.Completed) {
|
||||||
|
const recognitionResult: RecognitionResult = {
|
||||||
|
id: fileId,
|
||||||
|
file_id: fileId,
|
||||||
|
markdown_content: result.markdown,
|
||||||
|
latex_content: result.latex,
|
||||||
|
mathml_content: result.mathml,
|
||||||
|
mml: result.mml,
|
||||||
|
rendered_image_path: result.image_blob || null,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
resultsCache.current[fileId] = recognitionResult;
|
||||||
|
|
||||||
|
if (selectedFileIdRef.current === fileId) {
|
||||||
|
setSelectedResult(recognitionResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(pollingIntervals.current[taskNo]);
|
||||||
|
delete pollingIntervals.current[taskNo];
|
||||||
|
|
||||||
|
setFiles(prevFiles => prevFiles.map(f => {
|
||||||
|
if (f.id === fileId) return { ...f, status: 'failed' };
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
|
||||||
|
alert(t.alerts.taskTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error);
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(pollingIntervals.current[taskNo]);
|
||||||
|
delete pollingIntervals.current[taskNo];
|
||||||
|
setFiles(prevFiles => prevFiles.map(f => {
|
||||||
|
if (f.id === fileId) return { ...f, status: 'failed' };
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
alert(t.alerts.networkError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (uploadFiles: File[]) => {
|
||||||
|
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
||||||
|
openAuthModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let successfulUploads = 0;
|
||||||
|
|
||||||
|
for (const file of uploadFiles) {
|
||||||
|
const fileHash = await uploadService.calculateMD5(file);
|
||||||
|
const signatureData = await uploadService.uploadFile(file);
|
||||||
|
|
||||||
|
const taskData = await uploadService.createRecognitionTask(
|
||||||
|
signatureData.path,
|
||||||
|
fileHash,
|
||||||
|
file.name
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileId = taskData.task_no || crypto.randomUUID();
|
||||||
|
|
||||||
|
const newFile: FileRecord = {
|
||||||
|
id: fileId,
|
||||||
|
user_id: user?.id || null,
|
||||||
|
filename: file.name,
|
||||||
|
file_path: URL.createObjectURL(file),
|
||||||
|
file_type: file.type,
|
||||||
|
file_size: file.size,
|
||||||
|
thumbnail_path: null,
|
||||||
|
status: 'processing',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setFiles(prevFiles => [newFile, ...prevFiles]);
|
||||||
|
setSelectedFileId(newFile.id);
|
||||||
|
|
||||||
|
if (taskData.task_no) {
|
||||||
|
startPolling(taskData.task_no, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
successfulUploads += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user && successfulUploads > 0) {
|
||||||
|
incrementGuestUsage();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading files:', error);
|
||||||
|
alert(`${t.alerts.uploadFailed}: ` + (error instanceof Error ? error.message : 'Unknown error'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initializing) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">{t.common.loading}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SEOHead
|
||||||
|
title="Workspace"
|
||||||
|
description="TexPixel formula recognition workspace"
|
||||||
|
path="/app"
|
||||||
|
noindex
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Left Sidebar */}
|
||||||
|
<div
|
||||||
|
ref={sidebarRef}
|
||||||
|
className="flex-shrink-0 bg-white border-r border-gray-200 relative transition-all duration-300 ease-in-out"
|
||||||
|
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
|
||||||
|
>
|
||||||
|
<LeftSidebar
|
||||||
|
files={files}
|
||||||
|
selectedFileId={selectedFileId}
|
||||||
|
onFileSelect={setSelectedFileId}
|
||||||
|
onUploadClick={() => {
|
||||||
|
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
||||||
|
openAuthModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowUploadModal(true);
|
||||||
|
}}
|
||||||
|
canUploadAnonymously={canUploadAnonymously}
|
||||||
|
onRequireAuth={openAuthModal}
|
||||||
|
isCollapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
onUploadFiles={handleUpload}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={loadMoreFiles}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 w-1 h-full cursor-col-resize hover:bg-blue-400 z-50 opacity-0 hover:opacity-100 transition-opacity"
|
||||||
|
onMouseDown={startResizing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Content: File Preview */}
|
||||||
|
<div className="flex-1 flex min-w-0 flex-col bg-gray-100/50">
|
||||||
|
<FilePreview file={selectedFile} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Result: Recognition Result */}
|
||||||
|
<div className="flex-1 flex min-w-0 flex-col bg-white">
|
||||||
|
<ResultPanel
|
||||||
|
result={selectedResult}
|
||||||
|
fileStatus={selectedFile?.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showUploadModal && (
|
||||||
|
<UploadModal
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
onUpload={handleUpload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAuthModal && (
|
||||||
|
<AuthModal
|
||||||
|
onClose={() => setShowAuthModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<UserGuide
|
||||||
|
isOpen={showUserGuide}
|
||||||
|
onClose={() => setShowUserGuide(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl p-8">
|
||||||
|
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-900 font-medium">{t.common.processing}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user