ge-tool/App.tsx
2025-12-10 13:41:43 +07:00

2078 lines
77 KiB
TypeScript
Executable File

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<NodeJS.Timeout | null>(null);
// Operation lock to prevent race conditions between actions
const operationLockRef = React.useRef<boolean>(false);
const operationQueueRef = React.useRef<(() => Promise<void>)[]>([]);
// Load current page from localStorage, default to 'permission'
const [currentPage, setCurrentPage] = useState<Page>(() => {
// 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<void>) => {
// 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<string>('');
const [geIdAndLang, setGeIdAndLang] = useState<string>('');
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [currentSubmission, setCurrentSubmission] = useState<{ username: string; geIdAndLang: string } | null>(null);
const [isUserModalOpen, setIsUserModalOpen] = useState<boolean>(false);
const [isCustomPathModalOpen, setIsCustomPathModalOpen] = useState<boolean>(false);
const [isQueueModalOpen, setIsQueueModalOpen] = useState<boolean>(false);
const [queueItems, setQueueItems] = useState<GeIdItem[]>([]);
const [pendingSubmissionsCount, setPendingSubmissionsCount] = useState<number>(0);
const [isErrorDetailModalOpen, setIsErrorDetailModalOpen] = useState(false);
const [errorDetailContent, setErrorDetailContent] = useState<string | null>(null);
const [isProjectInfoModalOpen, setIsProjectInfoModalOpen] = useState(false);
const [projectInfoData, setProjectInfoData] = useState<ProjectDetails | null>(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<FileSystemItem[]>([]);
const [rawError, setRawError] = useState<string | null>(null);
const [relatedProjects, setRelatedProjects] = useState<Array<{ ge_id: string, lang: string }>>([]);
const [projectNote, setProjectNote] = useState<string | null>(null);
const [currentGeId, setCurrentGeId] = useState<string>('');
const [currentLang, setCurrentLang] = useState<string>('');
const [autoSearchQuery, setAutoSearchQuery] = useState<string>(''); // Auto search from 3rd param
// Track active polling intervals for cleanup
const sharingPollIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
// NEW: Unified state for all file downloads
const [allDownloads, setAllDownloads] = useState<FileDownload[]>([]);
// Computed values - group files by batch (memoized to prevent unnecessary re-renders)
const groupFilesByBatch = React.useCallback((downloads: FileDownload[]): Map<string, FileDownload[]> => {
const groups = new Map<string, FileDownload[]>();
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<string[]>([]);
const [isOtpModalOpen, setIsOtpModalOpen] = useState(false);
const [isOtpLoading, setIsOtpLoading] = useState(false);
const [otpError, setOtpError] = useState<string | null>(null);
const [pendingSearchParams, setPendingSearchParams] = useState<{ geId: string, lang: string } | null>(null);
const [otpCallback, setOtpCallback] = useState<((otpCode: string) => Promise<void>) | null>(null);
const [isDownloadButtonLoading, setIsDownloadButtonLoading] = useState(false);
const [currentSharingId, setCurrentSharingId] = useState<string>(''); // 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<string[]>([]);
const [navigationIndex, setNavigationIndex] = useState<number>(-1);
const [currentPath, setCurrentPath] = useState<string>('');
const [customPath, setCustomPath] = useState<string | null>(null);
const [hasCustomPath, setHasCustomPath] = useState<boolean>(false);
const [isSavingCustomPath, setIsSavingCustomPath] = useState<boolean>(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<string, number> = {
'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<string, any[]>();
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 (
<div className="min-h-screen bg-slate-900 flex flex-col items-center p-4 sm:p-6 lg:p-8">
<div className="w-full max-w-7xl mx-auto">
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
{currentPage === 'permission' && (
<main>
<header className="text-center mb-8"><h1 className="text-4xl font-bold text-white mb-2">Cấp quyền TMS</h1><p className="text-slate-400">Nhập thông tin đ cấp quyền xem lịch sử bên dưới.</p></header>
<div className="bg-slate-800/50 backdrop-blur-sm p-8 rounded-2xl shadow-2xl border border-slate-700 max-w-4xl mx-auto">
<SubmissionForm
username={username}
setUsername={setUsername}
geIdAndLang={geIdAndLang}
setGeIdAndLang={setGeIdAndLang}
handleSubmit={handlePermissionSubmit}
onManageUserClick={() => setIsUserModalOpen(true)}
onViewInfoClick={handleViewProjectInfo}
/>
{error && <p className="text-red-400 mt-4 text-center">{error}</p>}
</div>
<div className="max-w-4xl mx-auto"><QueueStatus currentSubmission={currentSubmission} queueItems={queueItems} pendingSubmissionsCount={pendingSubmissionsCount} onOpenQueueModal={() => setIsQueueModalOpen(true)} /></div>
<div className="mt-12 max-w-4xl mx-auto"><SubmissionHistory submissions={submissions} onErrorClick={handleErrorClick} onDelete={handleDeleteSubmission} onRetry={handleRetrySubmission} onPaste={handlePasteSubmission} /></div>
</main>
)}
{currentPage === 'rawDownload' && (
<main>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<div className="bg-slate-800/50 backdrop-blur-sm p-8 rounded-2xl shadow-2xl border border-slate-700 h-[450px] flex items-center">
<div className="w-full">
<RawDownloadForm
geIdAndLang={rawGeId}
setGeIdAndLang={setRawGeId}
isLoading={isSearching}
handleSubmit={handleRawSearch}
handleRawDownload={handleRawDownload}
isRawDownloading={isRawDownloading}
relatedProjects={relatedProjects}
projectNote={projectNote}
currentGeId={currentGeId}
currentLang={currentLang}
/>
</div>
</div>
<div className="h-[450px]">
<FileList
items={fileList}
isLoading={isSearching}
onDownload={handleStartDownload}
selectedIds={selectedFileIds}
onSelectItem={handleFileSelect}
onFolderDoubleClick={handleFolderDoubleClick}
onNavigateBack={handleNavigateBack}
onNavigateForward={handleNavigateForward}
canNavigateBack={navigationIndex > 0}
canNavigateForward={navigationIndex < navigationHistory.length - 1}
currentPath={currentPath}
currentMode={currentMode}
isDownloadButtonLoading={isDownloadButtonLoading}
hasCustomPath={hasCustomPath}
isCustomPath={currentPath === customPath}
onSaveCustomPath={handleSaveCustomPath}
isSavingCustomPath={isSavingCustomPath}
initialSearchQuery={autoSearchQuery}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
<div className="h-[1000px]">
<DownloadQueueStatus
geId=""
queue={memoizedQueueItems}
onCancelJob={handleCancelJob}
downloadingFilesCount={downloadingFilesCount}
/>
</div>
<div className="h-[1000px]">
<DownloadHistory
history={memoizedHistoryEntries}
onDelete={handleDeleteDownloadHistory}
onRetry={handleRetryDownloadHistory}
onErrorClick={handleErrorClick}
/>
</div>
</div>
</main>
)}
{currentPage === 'check' && (
<main>
<CheckPage />
</main>
)}
</div>
<UserManagementModal isOpen={isUserModalOpen} onClose={() => setIsUserModalOpen(false)} onAddUser={handleAddUserFromModal} />
<ProjectInfoModal
isOpen={isProjectInfoModalOpen}
onClose={() => setIsProjectInfoModalOpen(false)}
projectDetails={projectInfoData}
isLoading={isLoadingProjectInfo}
onDeleteMember={handleDeleteProjectMember}
/>
<CustomPathManagerModal
isOpen={isCustomPathModalOpen}
onClose={() => 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);
}
}
}}
/>
<QueueManagementModal isOpen={isQueueModalOpen} onClose={() => setIsQueueModalOpen(false)} queueItems={queueItems} onReorder={handleReorderQueue} onDelete={handleDeleteQueueItem} />
<ErrorDetailModal isOpen={isErrorDetailModalOpen} onClose={closeErrorModal} errorMessage={errorDetailContent} />
<OtpModal
isOpen={isOtpModalOpen}
onClose={() => {
setIsOtpModalOpen(false);
setOtpError(null);
setIsOtpLoading(false);
}}
onSubmit={handleOtpSubmit}
isLoading={isOtpLoading}
errorMessage={otpError}
/>
{/* Notification Manager - handles browser notifications */}
<NotificationManager />
</div>
);
};
export default App;