2078 lines
77 KiB
TypeScript
Executable File
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 và 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;
|