feat: migrate App.tsx logic to WorkspacePage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:20:01 +08:00
parent 274342aab6
commit e28b8294aa
2 changed files with 529 additions and 570 deletions

View File

@@ -1,572 +1,5 @@
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 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';
import { Navigate } from 'react-router-dom';
const PAGE_SIZE = 6;
const GUEST_USAGE_LIMIT = 3;
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 function App() {
return <Navigate to="/" replace />;
}
export default App;

526
src/pages/WorkspacePage.tsx Normal file
View 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>
)}
</>
);
}