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 { 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
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