From e28b8294aa5004e86de251c1f363d966a3a9e771 Mon Sep 17 00:00:00 2001 From: yoge Date: Wed, 25 Mar 2026 13:20:01 +0800 Subject: [PATCH] feat: migrate App.tsx logic to WorkspacePage Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 573 +----------------------------------- src/pages/WorkspacePage.tsx | 526 +++++++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+), 570 deletions(-) create mode 100644 src/pages/WorkspacePage.tsx diff --git a/src/App.tsx b/src/App.tsx index ead6205..330e5e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]); - const [selectedFileId, setSelectedFileId] = useState(null); - const [selectedResult, setSelectedResult] = useState(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(() => { - 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(null); - - // Polling intervals refs to clear them on unmount - const pollingIntervals = useRef>({}); - // Ref to track latest selectedFileId for use in polling callbacks (avoids stale closure) - const selectedFileIdRef = useRef(null); - // Cache for recognition results (keyed by task_id/file_id) - const resultsCache = useRef>({}); - // 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 = { - [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 ( -
-
-
-

{t.common.loading}

-
-
- ); - } - - return ( -
- -
- {/* Left Sidebar */} -
- { - 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 && ( -
- )} -
- - {/* Middle Content: File Preview */} -
- -
- - {/* Right Result: Recognition Result */} -
- -
- - {showUploadModal && ( - setShowUploadModal(false)} - onUpload={handleUpload} - /> - )} - - {showAuthModal && ( - setShowAuthModal(false)} - /> - )} - - setShowUserGuide(false)} - /> - - {loading && ( -
-
-
-

{t.common.processing}

-
-
- )} -
-
- ); +export default function App() { + return ; } - -export default App; diff --git a/src/pages/WorkspacePage.tsx b/src/pages/WorkspacePage.tsx new file mode 100644 index 0000000..7da4479 --- /dev/null +++ b/src/pages/WorkspacePage.tsx @@ -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([]); + const [selectedFileId, setSelectedFileId] = useState(null); + const [selectedResult, setSelectedResult] = useState(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(() => { + 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(null); + + const pollingIntervals = useRef>({}); + const selectedFileIdRef = useRef(null); + const resultsCache = useRef>({}); + 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 = { + [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 ( +
+
+
+

{t.common.loading}

+
+
+ ); + } + + return ( + <> + + + {/* Left Sidebar */} +
+ { + 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 && ( +
+ )} +
+ + {/* Middle Content: File Preview */} +
+ +
+ + {/* Right Result: Recognition Result */} +
+ +
+ + {showUploadModal && ( + setShowUploadModal(false)} + onUpload={handleUpload} + /> + )} + + {showAuthModal && ( + setShowAuthModal(false)} + /> + )} + + setShowUserGuide(false)} + /> + + {loading && ( +
+
+
+

{t.common.processing}

+
+
+ )} + + ); +}