import React, { useState, useCallback, useEffect, useRef } from 'react'; import type { Submission, GeIdResult, ResultDetail, FileSystemItem, DownloadHistoryEntry, DownloadJob, DownloadedFile, FileDownload, DownloadBatch, ProjectDetails } from './types'; import SubmissionForm from './components/SubmissionForm'; import SubmissionHistory from './components/SubmissionHistory'; import UserManagementModal from './components/UserManagementModal'; import CustomPathManagerModal from './components/CustomPathManagerModal'; import QueueStatus from './components/QueueStatus'; import type { GeIdItem } from './components/QueueStatus'; import QueueManagementModal from './components/QueueManagementModal'; import ErrorDetailModal from './components/ErrorDetailModal'; import Navigation from './components/Navigation'; import RawDownloadForm from './components/RawDownloadForm'; import FileList from './components/FileList'; import DownloadQueueStatus from './components/DownloadQueueStatus'; import DownloadHistory from './components/DownloadHistory'; import OtpModal from './components/OtpModal'; import ProjectInfoModal from './components/ProjectInfoModal'; import NotificationManager, { addMySubmission } from './components/NotificationManager'; import CheckPage from './components/CheckPage'; import { useRealtimeDownloads, DownloadRecord } from './utils/use-realtime-downloads'; import { useRealtimeSubmissions, SubmissionRecord } from './utils/use-realtime-submissions'; import { useTabVisibility } from './hooks'; // Helper function to format file size const formatFileSize = (bytes: number | undefined): string | undefined => { if (!bytes || bytes === 0) return undefined; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } // Format with 2 decimal places for KB and above, no decimals for bytes const formatted = unitIndex === 0 ? size.toString() : size.toFixed(2); return `${formatted} ${units[unitIndex]}`; }; // --- MOCK DATA --- // --- APP COMPONENT --- type Page = 'permission' | 'rawDownload' | 'check'; const App: React.FC = () => { // Track tab visibility để pause polling khi tab ẩn const isTabVisibleRef = useTabVisibility(); // Navigation debounce timer ref const navigationTimerRef = React.useRef(null); // Operation lock to prevent race conditions between actions const operationLockRef = React.useRef(false); const operationQueueRef = React.useRef<(() => Promise)[]>([]); // Load current page from localStorage, default to 'permission' const [currentPage, setCurrentPage] = useState(() => { // Check URL params first if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search); const pageParam = params.get('page'); if (pageParam === 'permission' || pageParam === 'rawDownload' || pageParam === 'check') { return pageParam; } } const saved = localStorage.getItem('currentPage'); return (saved === 'permission' || saved === 'rawDownload' || saved === 'check') ? saved : 'permission'; }); // Save current page to localStorage whenever it changes React.useEffect(() => { localStorage.setItem('currentPage', currentPage); // Update URL without reloading const url = new URL(window.location.href); url.searchParams.set('page', currentPage); window.history.pushState({}, '', url); }, [currentPage]); // Cleanup navigation timer on unmount React.useEffect(() => { return () => { if (navigationTimerRef.current) { clearTimeout(navigationTimerRef.current); } }; }, []); // Helper: Execute operation with lock to prevent race conditions // Operations are queued and executed sequentially const executeWithLock = React.useCallback(async (operation: () => Promise) => { // Add to queue operationQueueRef.current.push(operation); // If already processing, let the queue handler process it if (operationLockRef.current) return; // Process queue operationLockRef.current = true; try { while (operationQueueRef.current.length > 0) { const nextOperation = operationQueueRef.current.shift(); if (nextOperation) { await nextOperation(); // Small delay between operations to prevent UI jank await new Promise(resolve => setTimeout(resolve, 200)); } } } finally { operationLockRef.current = false; } }, []); // State for Permission Page const [username, setUsername] = useState(''); const [geIdAndLang, setGeIdAndLang] = useState(''); const [submissions, setSubmissions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [currentSubmission, setCurrentSubmission] = useState<{ username: string; geIdAndLang: string } | null>(null); const [isUserModalOpen, setIsUserModalOpen] = useState(false); const [isCustomPathModalOpen, setIsCustomPathModalOpen] = useState(false); const [isQueueModalOpen, setIsQueueModalOpen] = useState(false); const [queueItems, setQueueItems] = useState([]); const [pendingSubmissionsCount, setPendingSubmissionsCount] = useState(0); const [isErrorDetailModalOpen, setIsErrorDetailModalOpen] = useState(false); const [errorDetailContent, setErrorDetailContent] = useState(null); const [isProjectInfoModalOpen, setIsProjectInfoModalOpen] = useState(false); const [projectInfoData, setProjectInfoData] = useState(null); const [isLoadingProjectInfo, setIsLoadingProjectInfo] = useState(false); // State for Raw Download Page const [rawGeId, setRawGeId] = useState(''); const [isSearching, setIsSearching] = useState(false); const [isRawDownloading, setIsRawDownloading] = useState(false); const [fileList, setFileList] = useState([]); const [rawError, setRawError] = useState(null); const [relatedProjects, setRelatedProjects] = useState>([]); const [projectNote, setProjectNote] = useState(null); const [currentGeId, setCurrentGeId] = useState(''); const [currentLang, setCurrentLang] = useState(''); const [autoSearchQuery, setAutoSearchQuery] = useState(''); // Auto search from 3rd param // Track active polling intervals for cleanup const sharingPollIntervalRef = React.useRef(null); // NEW: Unified state for all file downloads const [allDownloads, setAllDownloads] = useState([]); // Computed values - group files by batch (memoized to prevent unnecessary re-renders) const groupFilesByBatch = React.useCallback((downloads: FileDownload[]): Map => { const groups = new Map(); downloads.forEach(file => { const batchId = file.batch_id || 'unknown'; if (!groups.has(batchId)) { groups.set(batchId, []); } groups.get(batchId)!.push(file); }); return groups; }, []); const activeDownloads = React.useMemo(() => allDownloads.filter(d => d.status === 'pending' || d.status === 'downloading' ), [allDownloads] ); const historyDownloads = React.useMemo(() => allDownloads.filter(d => d.status === 'completed' || d.status === 'failed' || d.status === 'cancelled' ), [allDownloads] ); // Memoize downloading count to avoid recalculation on every render const downloadingFilesCount = React.useMemo(() => allDownloads.filter(d => d.status === 'downloading').length, [allDownloads] ); // Error modal handlers - stable references const showCriticalError = useCallback((message: string) => { setErrorDetailContent(message); setIsErrorDetailModalOpen(true); }, []); const handleErrorClick = useCallback((details: string) => { setErrorDetailContent(details); setIsErrorDetailModalOpen(true); }, []); const closeErrorModal = useCallback(() => { setIsErrorDetailModalOpen(false); }, []); const [selectedFileIds, setSelectedFileIds] = useState([]); const [isOtpModalOpen, setIsOtpModalOpen] = useState(false); const [isOtpLoading, setIsOtpLoading] = useState(false); const [otpError, setOtpError] = useState(null); const [pendingSearchParams, setPendingSearchParams] = useState<{ geId: string, lang: string } | null>(null); const [otpCallback, setOtpCallback] = useState<((otpCode: string) => Promise) | null>(null); const [isDownloadButtonLoading, setIsDownloadButtonLoading] = useState(false); const [currentSharingId, setCurrentSharingId] = useState(''); // Track if viewing sharing link const [currentSharingContext, setCurrentSharingContext] = useState<{ geId: string, lang: string } | null>(null); // Track GE ID + Lang for sharing link const [currentMode, setCurrentMode] = useState<'api' | 'sharing' | null>(null); // Track current download mode // Navigation history for folder browsing const [navigationHistory, setNavigationHistory] = useState([]); const [navigationIndex, setNavigationIndex] = useState(-1); const [currentPath, setCurrentPath] = useState(''); const [customPath, setCustomPath] = useState(null); const [hasCustomPath, setHasCustomPath] = useState(false); const [isSavingCustomPath, setIsSavingCustomPath] = useState(false); // Setup global function for opening custom path manager modal React.useEffect(() => { (window as any).openCustomPathManager = () => { setIsCustomPathModalOpen(true); }; return () => { delete (window as any).openCustomPathManager; }; }, []); // Helper: Convert Supabase record to FileDownload format const convertToFileDownload = useCallback((record: DownloadRecord): FileDownload => ({ id: record.id, batch_id: record.batch_id, ge_id: record.ge_id, lang: record.lang, file_name: record.file_name, file_path: record.file_path, mode: record.mode, status: record.status, destination_path: record.destination_path || undefined, file_size: record.file_size || undefined, downloaded_size: record.downloaded_size || 0, progress_percent: record.progress_percent || 0, created_at: record.created_at || '', started_at: record.started_at || undefined, completed_at: record.completed_at || undefined, error_message: record.error_message || undefined, retry_count: record.retry_count || 0, sharing_id: record.sharing_id || undefined, mongodb_path: record.mongodb_path || undefined, }), []); // Realtime handlers for downloads const handleDownloadInsert = useCallback((record: DownloadRecord) => { const newDownload = convertToFileDownload(record); setAllDownloads(prev => { // Check if already exists (avoid duplicates) if (prev.some(d => d.id === newDownload.id)) return prev; return [newDownload, ...prev]; }); }, [convertToFileDownload]); const handleDownloadUpdate = useCallback((record: DownloadRecord) => { const updated = convertToFileDownload(record); setAllDownloads(prev => prev.map(d => { if (d.id !== updated.id) return d; // Smart merge: prevent progress regression const merged = { ...updated }; // Rule 1: Never downgrade progress if (d.progress_percent && updated.progress_percent) { merged.progress_percent = Math.max(d.progress_percent, updated.progress_percent); } // Rule 2: Never reduce downloaded_size if (d.downloaded_size && updated.downloaded_size) { merged.downloaded_size = Math.max(d.downloaded_size, updated.downloaded_size); } // Rule 3: Status priority (don't revert completed → downloading) const statusPriority: Record = { 'completed': 4, 'failed': 3, 'downloading': 2, 'pending': 1, 'cancelled': 0 }; if ((statusPriority[d.status] || 0) > (statusPriority[updated.status] || 0)) { merged.status = d.status; } return merged; })); }, [convertToFileDownload]); const handleDownloadDelete = useCallback((record: DownloadRecord) => { setAllDownloads(prev => prev.filter(d => d.id !== record.id)); }, []); // Subscribe to realtime downloads (only when on rawDownload page) useRealtimeDownloads(currentPage === 'rawDownload' ? { onInsert: handleDownloadInsert, onUpdate: handleDownloadUpdate, onDelete: handleDownloadDelete, } : {}); // Realtime handlers for TMS submissions const handleSubmissionUpdate = useCallback((record: SubmissionRecord) => { // Only handle completed/failed submissions for history if (record.status === 'completed' || record.status === 'failed') { const results = record.results ? (typeof record.results === 'string' ? JSON.parse(record.results) : record.results) : []; const newSubmission: Submission = { id: record.submission_id, username: (record.input?.username_list || []).join('\n'), geIdAndLang: record.input?.ge_input || '', timestamp: new Date(record.created_at), results: formatResults(results, record.input?.ge_input || '') }; setSubmissions(prev => { // Check if already exists if (prev.some(s => s.id === newSubmission.id)) { return prev.map(s => s.id === newSubmission.id ? newSubmission : s); } return [newSubmission, ...prev].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime() ); }); // Clear queue when submission completes setQueueItems([]); setCurrentSubmission(null); setIsLoading(false); } }, []); const handleSubmissionDelete = useCallback((record: SubmissionRecord) => { setSubmissions(prev => prev.filter(s => s.id !== record.submission_id)); }, []); // Subscribe to realtime submissions (only when on permission page) useRealtimeSubmissions(currentPage === 'permission' ? { onUpdate: handleSubmissionUpdate, onDelete: handleSubmissionDelete, submissionType: 'tms_permission', } : {}); // Initial fetch for downloads when entering rawDownload page React.useEffect(() => { let isMounted = true; const loadAllDownloads = async () => { if (!isMounted) return; try { const response = await fetch('/api/downloads?limit=500'); const data = await response.json(); if (isMounted && data.success && data.downloads) { setAllDownloads(data.downloads as FileDownload[]); } } catch (error) { console.error('Failed to load downloads:', error); } }; // Only load when on rawDownload page if (currentPage === 'rawDownload') { loadAllDownloads(); } return () => { isMounted = false; // Cleanup sharing poll interval if (sharingPollIntervalRef.current) { clearInterval(sharingPollIntervalRef.current); sharingPollIntervalRef.current = null; } }; }, [currentPage]); // Load Permission page data React.useEffect(() => { if (currentPage === 'permission') { loadSubmissionsHistory(); } }, [currentPage]); // Load submissions history from Supabase const loadSubmissionsHistory = async () => { try { console.log('Loading submissions history...'); const response = await fetch('/api/submissions?limit=30'); const data = await response.json(); console.log('Submissions history response:', data); if (data.success && data.data) { // Convert Supabase format to Submission format const history: Submission[] = data.data .filter((s: any) => s.status === 'completed' || s.status === 'failed') .map((s: any) => ({ id: s.submission_id, username: (s.input?.username_list || []).join('\n'), geIdAndLang: s.input?.ge_input || '', timestamp: new Date(s.created_at), results: s.results ? formatResults( typeof s.results === 'string' ? JSON.parse(s.results) : s.results, s.input?.ge_input || '' ) : [] })) .sort((a: Submission, b: Submission) => b.timestamp.getTime() - a.timestamp.getTime() ); setSubmissions(history); } } catch (error) { console.error('Failed to load submissions history:', error); } }; const handlePermissionSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!username.trim() || !geIdAndLang.trim()) { setError('Cả hai trường Username và GE ID & Lang đều là bắt buộc.'); return; } setError(null); setIsLoading(true); setCurrentSubmission({ username, geIdAndLang }); const geIdLines = geIdAndLang.trim().split('\n').filter(Boolean); const usernames = username.trim().split('\n').filter(Boolean); // Create initial queue items for UI const initialQueueItems: GeIdItem[] = geIdLines.map((line, index) => { const [id = '', lang = ''] = line.split(/\s+/); return { key: `${Date.now()}-${index}`, id, lang, status: 'waiting', usernames: username }; }); setQueueItems(initialQueueItems); try { // Create submission in backend (src/ backend via Vite proxy) const response = await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ submission_type: 'tms_permission', username_list: usernames, ge_input: geIdAndLang }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Không thể tạo submission'); } console.log('[handlePermissionSubmit] Created submission:', data); // Start polling for status (submission_id returned from backend) const submissionId = data.data?.submission_id; if (submissionId) { // Save to localStorage for notification filtering addMySubmission(submissionId); startPollingSubmission(submissionId); } // Giữ nguyên nội dung input (không xoá form) } catch (error: any) { console.error('[handlePermissionSubmit] Error:', error); setError(error.message || 'Có lỗi xảy ra khi gửi yêu cầu'); setIsLoading(false); setCurrentSubmission(null); setQueueItems([]); } }; // Poll submission status const startPollingSubmission = (submissionId: string) => { const pollInterval = setInterval(async () => { // Skip polling if tab is hidden if (!isTabVisibleRef.current) return; try { const response = await fetch(`/api/submissions/${submissionId}`); const data = await response.json(); if (!data.success) { clearInterval(pollInterval); return; } const submission = data.data; console.log('[startPollingSubmission] Status:', submission.status); // Update queue items based on status if (submission.status === 'processing') { // Mark first waiting item as processing setQueueItems(prev => { const waitingIndex = prev.findIndex(item => item.status === 'waiting'); if (waitingIndex !== -1) { return prev.map((item, idx) => idx === waitingIndex ? { ...item, status: 'processing' } : item ); } return prev; }); } else if (submission.status === 'completed' || submission.status === 'failed') { clearInterval(pollInterval); // Parse results and update queue if (submission.results) { const results = typeof submission.results === 'string' ? JSON.parse(submission.results) : submission.results; // Update queue items to done/error setQueueItems(prev => prev.map(item => ({ ...item, status: 'done' // Simplified for now }))); // Add to submissions history const newSubmission: Submission = { id: submissionId, username: submission.usernames.join('\n'), geIdAndLang: submission.ge_input, timestamp: new Date(submission.created_at), results: formatResults(results, submission.ge_input) }; setSubmissions(prev => [newSubmission, ...prev].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime() )); } setIsLoading(false); setCurrentSubmission(null); setQueueItems([]); } } catch (error) { console.error('[startPollingSubmission] Polling error:', error); } }, 2000); // Poll every 2 seconds }; // Format backend results to match frontend structure const formatResults = (backendResults: any[], geInput: string): GeIdResult[] => { if (!backendResults || !Array.isArray(backendResults)) { return []; } // Group results by ge_id + lang const groupedResults = new Map(); for (const result of backendResults) { // Handle both old format [username, message] and new format {ge_id, lang, status, message, ...} if (Array.isArray(result)) { // Old format: [username, message] - skip or handle gracefully continue; } const key = `${result.ge_id} ${result.lang}`; if (!groupedResults.has(key)) { groupedResults.set(key, []); } groupedResults.get(key)!.push(result); } // Convert to GeIdResult format const geIdResults: GeIdResult[] = []; for (const [geIdLang, results] of groupedResults) { const details: ResultDetail[] = results.map((r: any) => ({ username: r.username || 'Unknown', url: r.tms_url || '', status: r.status === 'success' ? 'success' : 'error', message: r.message || '', ...(r.status === 'error' && { errorDetails: r.message }) })); geIdResults.push({ geIdAndLang: geIdLang, completionTime: new Date(results[0]?.processed_at || new Date()), details }); } return geIdResults; }; const handleRawDownload = async (e: React.FormEvent) => { e.preventDefault(); if (!rawGeId.trim()) { return; // Silent validation } const lines = rawGeId.trim().split('\n'); if (lines.length === 0) { return; // Silent validation } const firstLine = lines[0].trim(); const parts = firstLine.split(/\s+/); const [geId, lang, searchQuery] = parts; // Extract search query (3rd param) if (!geId || !lang) { return; // Silent validation } setIsRawDownloading(true); setFileList([]); setSelectedFileIds([]); setCurrentSharingId(''); setCurrentMode('sharing'); // Set mode to Sharing setCurrentGeId(geId); setCurrentLang(lang.toUpperCase()); setAutoSearchQuery(searchQuery || ''); // Set auto search query from 3rd param // Reset navigation history setNavigationHistory([]); setNavigationIndex(-1); setCurrentPath(''); try { // Query MongoDB for linkRaw const dbResponse = await fetch('/api/sharing-link/get-from-db', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ge_id: geId, lang: lang }) }); if (!dbResponse.ok) { const errorData = await dbResponse.json(); // Format error message with record details let errorMessage = ''; if (errorData.detail?.error) { errorMessage = `${errorData.detail.error}\n\n`; if (errorData.detail.record) { const rec = errorData.detail.record; errorMessage += `Record:\n`; errorMessage += `- GE ID: ${rec.geId}\n`; errorMessage += `- Lang: ${rec.lang}\n`; errorMessage += `- linkRaw: ${rec.linkRaw || '(trống)'}\n`; errorMessage += `- path: ${rec.path || '(trống)'}`; } else if (errorData.detail.records) { errorMessage += `Tìm thấy ${errorData.detail.records.length} records:\n\n`; errorData.detail.records.forEach((rec: any, idx: number) => { errorMessage += `Record ${idx + 1}:\n`; errorMessage += `- GE ID: ${rec.geId}\n`; errorMessage += `- Lang: ${rec.lang}\n`; errorMessage += `- linkRaw: ${rec.linkRaw || '(trống)'}\n`; errorMessage += `- path: ${rec.path || '(trống)'}\n\n`; }); } else if (errorData.detail.query) { errorMessage += `Query không tìm thấy:\n`; errorMessage += `- GE ID: ${errorData.detail.query.geId}\n`; errorMessage += `- Lang: ${errorData.detail.query.lang}`; } } else { errorMessage = errorData.detail || 'Lỗi không xác định'; } showCriticalError(errorMessage); setIsRawDownloading(false); return; } const dbData = await dbResponse.json(); const sharingLink = dbData.sharing_link; // Fetch related projects (tựa cùng raw) try { const relatedResponse = await fetch(`/api/sharing-link/related-projects?link_raw=${encodeURIComponent(sharingLink)}`); if (relatedResponse.ok) { const relatedData = await relatedResponse.json(); if (relatedData.success && relatedData.projects) { setRelatedProjects(relatedData.projects); } } } catch (err) { console.error('Failed to fetch related projects:', err); // Non-critical error - continue anyway } // Fetch project note try { const noteResponse = await fetch(`/api/sharing-link/project-note?ge_id=${encodeURIComponent(geId)}&lang=${encodeURIComponent(lang)}`); if (noteResponse.ok) { const noteData = await noteResponse.json(); if (noteData.success) { setProjectNote(noteData.note); } } } catch (err) { console.error('Failed to fetch project note:', err); // Non-critical error - continue anyway } // Fetch custom path for this GE ID let fetchedCustomPath: string | null = null; try { const customPathResponse = await fetch(`/api/custom-paths/${encodeURIComponent(geId)}`); if (customPathResponse.ok) { const customPathData = await customPathResponse.json(); if (customPathData.success && customPathData.custom_path) { fetchedCustomPath = customPathData.custom_path; setCustomPath(customPathData.custom_path); setHasCustomPath(true); } else { setCustomPath(null); setHasCustomPath(false); } } else { setCustomPath(null); setHasCustomPath(false); } } catch (err) { console.error('Failed to fetch custom path:', err); setCustomPath(null); setHasCustomPath(false); } // Process sharing link const processResponse = await fetch('/api/sharing-link/process', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: sharingLink }) }); const submitData = await processResponse.json(); if (!submitData.success) { showCriticalError(submitData.message || 'Lỗi xử lý sharing link'); setIsRawDownloading(false); return; } // Poll for result const requestId = submitData.request_id; // Clear any existing polling interval if (sharingPollIntervalRef.current) { clearInterval(sharingPollIntervalRef.current); } const pollInterval = setInterval(async () => { // Skip polling if tab is hidden if (!isTabVisibleRef.current) return; try { // Check if OTP is required const otpStatusResponse = await fetch('/api/sharing-link/otp-status'); const otpStatusData = await otpStatusResponse.json(); if (otpStatusData.otp_required) { if (sharingPollIntervalRef.current) { clearInterval(sharingPollIntervalRef.current); sharingPollIntervalRef.current = null; } setIsRawDownloading(false); // Show OTP modal setOtpError(null); setIsOtpLoading(false); setIsOtpModalOpen(true); setOtpCallback(() => async (otpCode: string) => { try { const otpResponse = await fetch('/api/sharing-link/submit-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp_code: otpCode }) }); const otpData = await otpResponse.json(); if (otpData.success) { setIsRawDownloading(true); handleRawDownload(e); // Retry } else { showCriticalError(otpData.message || 'OTP không hợp lệ'); } } catch (error) { showCriticalError('Lỗi gửi OTP'); console.error('OTP submission error:', error); } }); return; } // Continue checking result const resultResponse = await fetch(`/api/sharing-link/result/${requestId}`); const data = await resultResponse.json(); if (data.status === 'success') { if (sharingPollIntervalRef.current) { clearInterval(sharingPollIntervalRef.current); sharingPollIntervalRef.current = null; } // Convert files to FileSystemItem format const items: FileSystemItem[] = (data.files || []).map((file: any) => { // Format modified time - handle both Unix timestamp (number) and ISO string let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { // Unix timestamp - convert to readable date modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.is_folder ? 'folder' : 'file', size: file.is_folder ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); // Don't set fileList yet if we have custom path if (!fetchedCustomPath) { setFileList(items); } // Set navigation for sharing link const targetPath = fetchedCustomPath || data.path || '/'; // If custom path exists, navigate to it directly if (fetchedCustomPath) { // Fetch folder list at custom path try { const customFolderResponse = await fetch('/api/sharing-link/list-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sharing_id: data.sharing_id, folder_path: fetchedCustomPath }) }); if (customFolderResponse.ok) { const customFolderData = await customFolderResponse.json(); console.log('Custom path response:', customFolderData); if (customFolderData.status === 'success') { const customItems: FileSystemItem[] = (customFolderData.files || []).map((file: any) => { let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.is_folder ? 'folder' : 'file', size: file.is_folder ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); setFileList(customItems); console.log('Custom path files loaded:', customItems.length); } else { console.error('Custom path failed:', customFolderData.message); // Fall back to root folder setFileList(items); } } else { console.error('Custom folder response not ok:', customFolderResponse.status); setFileList(items); } } catch (err) { console.error('Failed to navigate to custom path:', err); // Fall back to root folder if custom path fails setFileList(items); } } // Setup navigation history correctly // If custom path exists, include root path in history so Back button works if (fetchedCustomPath && fetchedCustomPath !== (data.path || '/')) { setNavigationHistory([data.path || '/', fetchedCustomPath]); setNavigationIndex(1); // Current is at custom path (index 1) } else { setNavigationHistory([targetPath]); setNavigationIndex(0); } setCurrentPath(targetPath); setCurrentSharingId(data.sharing_id); // Save GE ID + Lang context for later download setCurrentSharingContext({ geId, lang }); setIsRawDownloading(false); } else if (data.status === 'error') { if (sharingPollIntervalRef.current) { clearInterval(sharingPollIntervalRef.current); sharingPollIntervalRef.current = null; } showCriticalError(data.message || 'Lỗi xử lý sharing link'); setIsRawDownloading(false); } } catch (error) { if (sharingPollIntervalRef.current) { clearInterval(sharingPollIntervalRef.current); sharingPollIntervalRef.current = null; } showCriticalError('Lỗi kết nối khi xử lý sharing link'); setIsRawDownloading(false); } }, 2000); // Store interval ref for cleanup sharingPollIntervalRef.current = pollInterval; } catch (error) { showCriticalError('Lỗi kết nối đến server'); setIsRawDownloading(false); } }; const handleRawSearch = async (e: React.FormEvent) => { e.preventDefault(); if (!rawGeId.trim()) { return; // Silent validation - no error message } const lines = rawGeId.trim().split('\n'); if (lines.length === 0) { return; // Silent validation - no error message } const firstLine = lines[0].trim(); // Parse GE ID + Lang (only for API download logic) const [geId, lang] = firstLine.split(/\s+/); if (!geId || !lang) { return; // Silent validation - no error message } setIsSearching(true); setFileList([]); setSelectedFileIds([]); setCurrentSharingId(''); // Reset sharing ID setCurrentSharingContext(null); // Reset sharing context setCurrentMode('api'); // Set mode to API setCurrentGeId(geId); setCurrentLang(lang.toUpperCase()); setAutoSearchQuery(''); // Clear search query // Reset navigation history on new search setNavigationHistory([]); setNavigationIndex(-1); setCurrentPath(''); try { // Fetch related projects (tựa cùng raw) try { const relatedResponse = await fetch(`/api/sharing-link/related-projects-by-ge?ge_id=${encodeURIComponent(geId)}&lang=${encodeURIComponent(lang)}`); if (relatedResponse.ok) { const relatedData = await relatedResponse.json(); if (relatedData.success && relatedData.projects) { setRelatedProjects(relatedData.projects); } } } catch (err) { console.error('Failed to fetch related projects:', err); // Non-critical error - continue anyway } // Fetch project note try { const noteResponse = await fetch(`/api/sharing-link/project-note?ge_id=${encodeURIComponent(geId)}&lang=${encodeURIComponent(lang)}`); if (noteResponse.ok) { const noteData = await noteResponse.json(); if (noteData.success) { setProjectNote(noteData.note); } } } catch (err) { console.error('Failed to fetch project note:', err); // Non-critical error - continue anyway } // Handle GE ID + Lang (API download logic - fetch from NAS via API) const response = await fetch('/api/raw-files/list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ge_id: geId, lang: lang }) }); const data = await response.json(); if (data.status === 'success') { // Convert files to FileSystemItem format const items: FileSystemItem[] = data.files.map((file: any) => { // Format modified time - handle both Unix timestamp (number) and ISO string let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { // Unix timestamp - convert to readable date modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.isdir ? 'folder' : 'file', size: file.isdir ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); setFileList(items); // Save context for later download operations setCurrentSharingContext({ geId, lang }); // Initialize navigation history with root path const rootPath = data.path || ''; setNavigationHistory([rootPath]); setNavigationIndex(0); setCurrentPath(rootPath); setIsSearching(false); } else if (data.status === 'otp_required') { // Need OTP - save params and show modal setPendingSearchParams({ geId, lang }); setOtpError(null); setIsOtpLoading(false); setIsOtpModalOpen(true); setIsSearching(false); } else { // Error - show in modal for critical errors showCriticalError(data.message || 'Có lỗi xảy ra khi tìm kiếm file'); setIsSearching(false); } } catch (error) { showCriticalError('Lỗi kết nối đến server. Vui lòng kiểm tra kết nối mạng.'); setIsSearching(false); } } const handleOtpSubmit = async (otpCode: string) => { setIsOtpLoading(true); setOtpError(null); try { // If there's a custom callback (sharing link), use it if (otpCallback) { await otpCallback(otpCode); setIsOtpModalOpen(false); setOtpCallback(null); setIsOtpLoading(false); return; } // Otherwise, use GE ID flow (existing logic) const response = await fetch('/api/raw-files/auth-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp_code: otpCode }) }); const data = await response.json(); if (data.status === 'success') { setIsOtpModalOpen(false); setIsOtpLoading(false); // Retry search with pending params if (pendingSearchParams) { setIsSearching(true); const retryResponse = await fetch('/api/raw-files/list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ge_id: pendingSearchParams.geId, lang: pendingSearchParams.lang }) }); const retryData = await retryResponse.json(); if (retryData.status === 'success') { const items: FileSystemItem[] = retryData.files.map((file: any) => { // Format modified time let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.isdir ? 'folder' : 'file', size: file.isdir ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); setFileList(items); } else { setRawError(retryData.message || 'Có lỗi xảy ra'); } setIsSearching(false); setPendingSearchParams(null); } } else { // OTP failed setIsOtpLoading(false); throw new Error(data.message || 'OTP không đúng'); } } catch (error) { setIsOtpLoading(false); setOtpError(error instanceof Error ? error.message : 'Có lỗi xảy ra khi xác thực OTP'); throw error; // Let OtpModal handle error display } } const handleFolderDoubleClick = async (folder: FileSystemItem) => { if (!folder.path) return; setIsSearching(true); setRawError(null); try { // Check mode to determine which API to call let response; let data; if (currentMode === 'sharing') { // === SHARING LINK MODE: Use sharing-link/list-folder endpoint === response = await fetch('/api/sharing-link/list-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sharing_id: currentSharingId, folder_path: folder.path }) }); data = await response.json(); if (data.status === 'success') { // Convert sharing link format to FileSystemItem const items: FileSystemItem[] = data.files.map((file: any) => { // Format modified time let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.is_folder ? 'folder' : 'file', size: file.is_folder ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); setFileList(items); setSelectedFileIds([]); // Update navigation history const newHistory = navigationHistory.slice(0, navigationIndex + 1); newHistory.push(folder.path); setNavigationHistory(newHistory); setNavigationIndex(newHistory.length - 1); setCurrentPath(folder.path); } else { showCriticalError(data.message || 'Có lỗi xảy ra khi tải thư mục'); } } else { // === API MODE: Use raw-files/list-folder endpoint === const [geId, lang] = rawGeId.trim().split(/\s+/); response = await fetch('/api/raw-files/list-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_path: folder.path, ge_id: geId, lang: lang }) }); data = await response.json(); if (data.status === 'success') { const items: FileSystemItem[] = data.files.map((file: any) => { // Format modified time let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.isdir ? 'folder' : 'file', size: file.isdir ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); setFileList(items); setSelectedFileIds([]); // Update navigation history const newHistory = navigationHistory.slice(0, navigationIndex + 1); newHistory.push(folder.path); setNavigationHistory(newHistory); setNavigationIndex(newHistory.length - 1); setCurrentPath(folder.path); } else if (data.status === 'otp_required') { setPendingSearchParams({ geId, lang }); setOtpError(null); setIsOtpLoading(false); setIsOtpModalOpen(true); } else { showCriticalError(data.message || 'Có lỗi xảy ra khi tải thư mục'); } } } catch (error) { showCriticalError('Lỗi kết nối đến server. Vui lòng kiểm tra kết nối mạng.'); } finally { setIsSearching(false); } }; const handleNavigateBack = async () => { if (navigationIndex <= 0) return; // Clear any pending navigation if (navigationTimerRef.current) { clearTimeout(navigationTimerRef.current); } const previousPath = navigationHistory[navigationIndex - 1]; // Use operation lock and debounce to prevent race conditions navigationTimerRef.current = setTimeout(() => { executeWithLock(async () => { setNavigationIndex(navigationIndex - 1); await loadFolderContents(previousPath); }); }, 300); }; const handleNavigateForward = async () => { if (navigationIndex >= navigationHistory.length - 1) return; // Clear any pending navigation if (navigationTimerRef.current) { clearTimeout(navigationTimerRef.current); } const nextPath = navigationHistory[navigationIndex + 1]; // Use operation lock and debounce to prevent race conditions navigationTimerRef.current = setTimeout(() => { executeWithLock(async () => { setNavigationIndex(navigationIndex + 1); await loadFolderContents(nextPath); }); }, 300); }; const loadFolderContents = async (folderPath: string) => { setIsSearching(true); try { // Check mode to determine which API to call let response; let data; if (currentMode === 'sharing') { // === SHARING LINK MODE === response = await fetch('/api/sharing-link/list-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sharing_id: currentSharingId, folder_path: folderPath }) }); data = await response.json(); if (data.status === 'success') { const items: FileSystemItem[] = data.files.map((file: any) => { // Format modified time let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.is_folder ? 'folder' : 'file', size: file.is_folder ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); setFileList(items); setSelectedFileIds([]); setCurrentPath(folderPath); } else { showCriticalError(data.message || 'Có lỗi xảy ra khi tải nội dung'); } } else { // === API MODE === const [geId, lang] = rawGeId.trim().split(/\s+/); response = await fetch('/api/raw-files/list-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_path: folderPath, ge_id: geId, lang: lang }) }); data = await response.json(); if (data.status === 'success') { const items: FileSystemItem[] = data.files.map((file: any) => { // Format modified time let modifiedStr: string | undefined = undefined; const timeValue = file.additional?.time?.mtime || file.modified || file.time; if (timeValue) { if (typeof timeValue === 'number') { modifiedStr = new Date(timeValue * 1000).toLocaleString('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { modifiedStr = timeValue; } } return { id: file.path || file.name, name: file.name, type: file.isdir ? 'folder' : 'file', size: file.isdir ? undefined : formatFileSize(file.additional?.size), modified: modifiedStr, path: file.path }; }); setFileList(items); setSelectedFileIds([]); setCurrentPath(folderPath); } else { showCriticalError(data.message || 'Có lỗi xảy ra khi tải nội dung'); } } } catch (error) { showCriticalError('Lỗi kết nối đến server. Vui lòng kiểm tra kết nối mạng.'); } finally { setIsSearching(false); } }; const handleSaveCustomPath = async () => { // Use operation lock to prevent race conditions await executeWithLock(async () => { if (!currentGeId || !currentLang || !currentPath) { console.error('Missing required data for saving custom path'); return; } setIsSavingCustomPath(true); try { const response = await fetch('/api/custom-paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ge_id: currentGeId, lang: currentLang, custom_path: currentPath }) }); const data = await response.json(); if (data.success) { setCustomPath(currentPath); setHasCustomPath(true); // Optional: Show success message console.log(`Custom path ${data.is_new ? 'saved' : 'updated'} successfully`); } else { showCriticalError(data.message || 'Lỗi khi lưu custom path'); } } catch (error) { showCriticalError('Lỗi kết nối đến server'); console.error('Save custom path error:', error); } finally { setIsSavingCustomPath(false); } }); }; const handleFileSelect = (id: string) => { setSelectedFileIds(prev => prev.includes(id) ? prev.filter(fileId => fileId !== id) : [...prev, id] ); }; const handleStartDownload = async () => { // Use operation lock to prevent race conditions await executeWithLock(async () => { const itemsToDownload = selectedFileIds.length > 0 ? fileList.filter(item => selectedFileIds.includes(item.id)) : fileList; const filesToDownload = itemsToDownload; if (filesToDownload.length === 0) return; // Parse GE ID and lang from input const [geId, lang] = rawGeId.trim().split(/\s+/); // Unified download logic for both modes await handleUnifiedDownload({ mode: currentMode, files: filesToDownload, geId: geId, lang: lang, sharingId: currentSharingId, sharingContext: currentSharingContext }); }); }; /** * Unified download handler for both API and Sharing modes. * Eliminates code duplication between handleGeIdDownload and handleSharingLinkDownload. */ const handleUnifiedDownload = async (options: { mode: 'api' | 'sharing' | null; files: FileSystemItem[]; geId?: string; lang?: string; sharingId?: string; sharingContext?: { geId: string; lang: string } | null; }) => { const { mode, files, geId, lang, sharingId, sharingContext } = options; // Validate mode if (!mode) { showCriticalError('Chế độ tải xuống không xác định. Vui lòng nhấn lại "Tải setting" hoặc "Tải raw".'); return; } // Validate required params for API mode if (mode === 'api' && (!geId || !lang)) { return; // Silent validation for API mode } // Show loading spinner setIsDownloadButtonLoading(true); setTimeout(() => setIsDownloadButtonLoading(false), 500); try { // Prepare files payload (normalize key names) const filesPayload = files.map(file => ({ name: file.name, path: file.id, isdir: file.type === 'folder', is_folder: file.type === 'folder' // Alias for compatibility })); // Build request payload based on mode let requestPayload: any; let endpoint: string; if (mode === 'api') { endpoint = '/api/batches/api'; requestPayload = { files: filesPayload, ge_id: geId, lang: lang }; } else { // Sharing mode endpoint = '/api/batches/sharing'; requestPayload = { sharing_id: sharingId, files: filesPayload }; // Add GE ID context if available if (sharingContext) { requestPayload.ge_id = sharingContext.geId; requestPayload.lang = sharingContext.lang; } } // Send request const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestPayload) }); const data = await response.json(); // Handle response if (!data.success) { showCriticalError(data.message || 'Có lỗi xảy ra khi tạo batch tải xuống'); } else { // Clear selection after successful batch creation setSelectedFileIds([]); } } catch (error) { console.error('Download error:', error); showCriticalError('Lỗi kết nối đến server. Vui lòng kiểm tra kết nối mạng.'); } }; // Helper: Convert FileDownload[] to legacy DownloadQueueItem format for components // Memoized to prevent unnecessary re-computation const convertToQueueItems = React.useCallback((downloads: FileDownload[]) => { const batches = groupFilesByBatch(downloads); const items: any[] = []; batches.forEach((files, batchId) => { const firstFile = files[0]; const allCompleted = files.every(f => f.status === 'completed'); const anyDownloading = files.some(f => f.status === 'downloading'); const batchStatus = anyDownloading ? 'processing' : allCompleted ? 'completed' : 'pending'; // Calculate batch-level progress const totalDownloaded = files.reduce((sum, f) => sum + (f.downloaded_size || 0), 0); const totalSize = files.reduce((sum, f) => sum + (f.file_size || 0), 0); const batchProgress = totalSize > 0 ? (totalDownloaded / totalSize) * 100 : 0; items.push({ key: batchId, name: `${firstFile.ge_id} ${firstFile.lang}`, status: batchStatus, geId: firstFile.ge_id, mode: firstFile.mode, jobId: batchId, // Use batch_id as jobId for cancel operations // mongodb_path usage: // - API mode: MongoDB folder_path // - Sharing mode: linkRaw URL mongoDbPath: firstFile.mongodb_path, sharingUrl: firstFile.mongodb_path, // Same as mongoDbPath for sharing mode // Pass full path to component, let FolderPathDisplay extract folder destinationPath: firstFile.destination_path || `${firstFile.ge_id}_${firstFile.lang.toUpperCase()}`, queuePosition: undefined, // Store created_at from first file for sorting created_at: firstFile.created_at, progressData: { total_files: files.length, current_file_index: files.filter(f => f.status === 'completed').length, // Batch-level progress (aggregated from all files) current_file_downloaded: totalDownloaded, current_file_total: totalSize > 0 ? totalSize : undefined, current_file_progress: batchProgress, files_status: files.map(f => ({ name: f.file_name, status: f.status, // Simple folder detection: Check if original file_path has NO extension // File: /path/file.psd → has extension → false // Folder: /path/[식자설정] → no extension → true is_folder: f.file_path ? !/\.[^/.]+$/.test(f.file_path) : false, size: f.file_size || 0, progress: typeof f.progress_percent === 'number' ? f.progress_percent : parseFloat(String(f.progress_percent || 0)), downloaded: f.downloaded_size || 0, total: f.file_size || 0 })) } }); }); // Sort batches by created_at DESC (newest first) items.sort((a, b) => { const dateA = new Date(a.created_at).getTime(); const dateB = new Date(b.created_at).getTime(); return dateB - dateA; // DESC order }); return items; }, [groupFilesByBatch]); // Helper: Convert FileDownload[] to legacy DownloadHistoryEntry format // Memoized to prevent unnecessary re-computation const convertToHistoryEntries = React.useCallback((downloads: FileDownload[]) => { const batches = groupFilesByBatch(downloads); const entries: DownloadHistoryEntry[] = []; batches.forEach((files, batchId) => { const firstFile = files[0]; const successCount = files.filter(f => f.status === 'completed').length; // Calculate duration let durationStr = ''; if (firstFile.started_at && firstFile.completed_at) { const start = new Date(firstFile.started_at); const end = new Date(firstFile.completed_at); const durationMs = end.getTime() - start.getTime(); const seconds = Math.floor(durationMs / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { durationStr = `${hours}h ${minutes % 60}m`; } else if (minutes > 0) { durationStr = `${minutes}m ${seconds % 60}s`; } else { durationStr = `${seconds}s`; } } entries.push({ id: batchId, geIdAndLang: `${firstFile.ge_id} ${firstFile.lang}`, timestamp: new Date(firstFile.created_at).toLocaleString('vi-VN') + (durationStr ? ` (${durationStr})` : ''), files: files.map(f => ({ name: f.file_name, status: f.status === 'completed' ? 'success' : 'error', message: f.error_message || (f.status === 'completed' ? 'Thành công' : 'Lỗi'), path: f.destination_path || '', file_size: f.file_size // Add file size from database })), // destinationPath not used in History display destinationPath: firstFile.destination_path || `raw/${firstFile.ge_id}_${firstFile.lang}`, // For sharing mode: use mongodb_path (if available) or extract from file_path // For API mode: use source_path mongoDbPath: (() => { if (firstFile.mongodb_path) { return firstFile.mongodb_path; // Sharing mode with explicit mongodb path } if (firstFile.source_path) { return firstFile.source_path; // API mode } // Sharing mode without mongodb_path: extract folder from file_path if (firstFile.file_path) { const segments = firstFile.file_path.split('/').filter(Boolean); segments.pop(); // Remove filename return '/' + segments.join('/'); } return undefined; })(), totalFiles: files.length, successCount: successCount }); }); return entries; }, [groupFilesByBatch]); // Memoize converted items to prevent UI flickering const memoizedQueueItems = React.useMemo(() => convertToQueueItems(activeDownloads), [activeDownloads, convertToQueueItems] ); const memoizedHistoryEntries = React.useMemo(() => convertToHistoryEntries(historyDownloads), [historyDownloads, convertToHistoryEntries] ); // OLD POLLING LOGIC DELETED - Replaced by single polling useEffect at top // Memoized handlers to prevent child component re-renders const handleAddUserFromModal = useCallback((usernameToAdd: string) => { setUsername(prev => prev.split('\n').filter(Boolean).includes(usernameToAdd) ? prev : (prev.trim() ? `${prev.trim()}\n${usernameToAdd}` : usernameToAdd)); }, []); const handleReorderQueue = useCallback((reorderedWaitingItems: GeIdItem[]) => setQueueItems(prev => [...prev.filter(i => i.status !== 'waiting'), ...reorderedWaitingItems]), []); const handleDeleteQueueItem = useCallback((keyToDelete: string) => setQueueItems(prev => prev.filter(item => item.key !== keyToDelete)), []); const handleViewProjectInfo = async () => { if (!geIdAndLang.trim()) { showCriticalError('Vui lòng nhập GE ID & Lang'); return; } // Parse first line only const firstLine = geIdAndLang.trim().split('\n')[0]; const parts = firstLine.split(/\s+/); const [geId, lang] = parts; if (!geId || !lang) { showCriticalError('Format không hợp lệ. Vui lòng nhập theo format: GE_ID LANG'); return; } setIsLoadingProjectInfo(true); setIsProjectInfoModalOpen(true); setProjectInfoData(null); try { const response = await fetch('/api/project-info', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ geId, lang }) }); const data = await response.json(); if (data.success) { setProjectInfoData(data.data); } else { showCriticalError(data.error || 'Không thể lấy thông tin project'); setIsProjectInfoModalOpen(false); } } catch (error: any) { showCriticalError('Lỗi kết nối đến server'); setIsProjectInfoModalOpen(false); } finally { setIsLoadingProjectInfo(false); } }; // Handle delete project member const handleDeleteProjectMember = async (projectId: number, email: string, lang?: string) => { try { const response = await fetch('/api/project-member', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId, email, lang }) }); const data = await response.json(); if (data.success) { // Refresh project info if (projectInfoData) { const updatedMembers = { ...projectInfoData.members }; delete updatedMembers[email]; setProjectInfoData({ ...projectInfoData, members: updatedMembers }); } } else { showCriticalError(data.error || 'Không thể xóa member'); } } catch (error: any) { showCriticalError('Lỗi kết nối đến server'); } }; const handleDeleteSubmission = useCallback(async (idToDelete: string) => { try { const response = await fetch(`/api/submissions/${idToDelete}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { setSubmissions(prev => prev.filter(sub => sub.id !== idToDelete)); } else { console.error('Failed to delete submission:', data); } } catch (error) { console.error('Failed to delete submission:', error); } }, []); const handleRetrySubmission = useCallback(async (submissionToRetry: Submission, errorGeIds: string[], errorUsernames: string[]) => { try { // Call API to create new submission with only error GE IDs and error usernames const response = await fetch(`/api/submissions/${submissionToRetry.id}/retry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ errorGeIds, errorUsernames }) }); const data = await response.json(); if (data.success) { // Scroll to top to see the new submission processing window.scrollTo({ top: 0, behavior: 'smooth' }); } else { console.error('Failed to retry submission:', data); } } catch (error) { console.error('Failed to retry submission:', error); } }, []); const handlePasteSubmission = useCallback((username: string, geIdAndLang: string) => { setUsername(username); setGeIdAndLang(geIdAndLang); window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); const handleDeleteDownloadHistory = useCallback(async (batchId: string) => { try { await fetch(`/api/batches/${batchId}`, { method: 'DELETE' }); // Batch sẽ tự biến mất khỏi historyDownloads qua realtime } catch (error) { console.error('Failed to delete download batch:', error); } }, []); const handleRetryDownloadHistory = useCallback((entry: DownloadHistoryEntry) => { setRawGeId(entry.geIdAndLang); window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); const handleCancelJob = useCallback(async (batchId: string) => { try { // Get all files in this batch const filesToCancel = allDownloads.filter(d => d.batch_id === batchId); // Cancel each file for (const file of filesToCancel) { await fetch(`/api/downloads/${file.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }) }); } } catch (error) { console.error('Cancel error:', error); } }, [allDownloads]); return (
{currentPage === 'permission' && (

Cấp quyền TMS

Nhập thông tin để cấp quyền và xem lịch sử bên dưới.

setIsUserModalOpen(true)} onViewInfoClick={handleViewProjectInfo} /> {error &&

{error}

}
setIsQueueModalOpen(true)} />
)} {currentPage === 'rawDownload' && (
0} canNavigateForward={navigationIndex < navigationHistory.length - 1} currentPath={currentPath} currentMode={currentMode} isDownloadButtonLoading={isDownloadButtonLoading} hasCustomPath={hasCustomPath} isCustomPath={currentPath === customPath} onSaveCustomPath={handleSaveCustomPath} isSavingCustomPath={isSavingCustomPath} initialSearchQuery={autoSearchQuery} />
)} {currentPage === 'check' && (
)}
setIsUserModalOpen(false)} onAddUser={handleAddUserFromModal} /> setIsProjectInfoModalOpen(false)} projectDetails={projectInfoData} isLoading={isLoadingProjectInfo} onDeleteMember={handleDeleteProjectMember} /> setIsCustomPathModalOpen(false)} onPathUpdated={async () => { // Reload custom path for current GE ID if available if (currentGeId) { try { const response = await fetch(`/api/custom-paths/${encodeURIComponent(currentGeId)}`); if (response.ok) { const data = await response.json(); if (data.success && data.custom_path) { setCustomPath(data.custom_path); setHasCustomPath(true); } else { setCustomPath(null); setHasCustomPath(false); } } } catch (err) { console.error('Failed to reload custom path:', err); } } }} /> setIsQueueModalOpen(false)} queueItems={queueItems} onReorder={handleReorderQueue} onDelete={handleDeleteQueueItem} /> { setIsOtpModalOpen(false); setOtpError(null); setIsOtpLoading(false); }} onSubmit={handleOtpSubmit} isLoading={isOtpLoading} errorMessage={otpError} /> {/* Notification Manager - handles browser notifications */}
); }; export default App;