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

235 lines
12 KiB
TypeScript
Executable File

import React, { useState, memo } from 'react';
import type { DownloadQueueItem } from '../types';
import TrashIcon from './TrashIcon';
import { TruncatedPath } from './TruncatedPath';
import { FolderPathDisplay } from './FolderPathDisplay';
import { CopyButtonWithModal } from './CopyButtonWithModal';
interface DownloadQueueItemProps {
item: DownloadQueueItem;
queuePosition?: number;
onCancel: (jobId: string) => void;
}
const DownloadQueueItem: React.FC<DownloadQueueItemProps> = ({ item, queuePosition, onCancel }) => {
const [isExpanded, setIsExpanded] = useState(true);
const isProcessing = item.status === 'processing';
const isWaiting = item.status === 'waiting' || item.status === 'pending';
const hasProgress = item.progressData && item.progressData.files_status;
// Format GE ID and Lang (uppercase lang)
const geIdAndLang = item.name.includes(' ')
? item.name.split(' ').map((part, idx) => idx === 1 ? part.toUpperCase() : part).join(' ')
: item.name;
// Get status color
const getStatusColor = () => {
if (isProcessing) return 'text-amber-400';
if (isWaiting) return 'text-blue-400';
return 'text-slate-400';
};
// Get status background
const getStatusBg = () => {
if (isProcessing) return 'bg-amber-900/30 border-amber-700/25';
if (isWaiting) return 'bg-blue-900/30 border-blue-700/25';
return 'bg-slate-900/30 border-slate-700/25';
};
// Extract paths based on mode
const isSharing = item.mode === 'sharing';
const sourcePath = isSharing ? (item.sharingUrl || '') : (item.mongoDbPath || item.name.split(' - ')[1] || '');
const sourceLabel = isSharing ? 'Sharing Link' : 'Lezhin Disk';
// Extract destination path from item data
const destinationPath = item.destinationPath || '';
// Format file size
const formatSize = (bytes: number) => {
if (bytes === 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
return (
<div className={`border rounded-lg p-4 ${getStatusBg()}`}>
{/* Header: GE ID, Queue Position, Cancel Button */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3 text-sm">
<span className="font-semibold text-white">{geIdAndLang}</span>
{queuePosition && (
<>
<span className="text-slate-500"></span>
<div className="flex items-center gap-1.5">
<div className="h-5 w-5 rounded-full bg-blue-500/20 border-2 border-blue-500 flex items-center justify-center">
<span className="text-xs text-blue-400 font-bold">{queuePosition}</span>
</div>
<span className={getStatusColor()}>
{isProcessing ? 'Đang xử lý' : 'Đang chờ'}
</span>
</div>
</>
)}
{!queuePosition && (
<>
<span className="text-slate-500"></span>
<span className={getStatusColor()}>
{isProcessing ? 'Đang xử lý' : 'Đang chờ'}
</span>
</>
)}
{/* Progress summary */}
{hasProgress && item.progressData && (
<>
<span className="text-slate-500"></span>
<span className="text-slate-300 text-xs">
{item.progressData.current_file_index || 0}/{item.progressData.total_files || 0} files
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{/* Expand/Collapse button */}
{hasProgress && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 hover:bg-slate-700/50 rounded transition-colors"
title={isExpanded ? "Thu gọn" : "Mở rộng"}
>
<svg viewBox="0 0 24 24" fill="currentColor"
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}>
<path fillRule="evenodd" d="M12.53 16.28a.75.75 0 0 1-1.06 0l-7.5-7.5a.75.75 0 0 1 1.06-1.06L12 14.69l6.97-6.97a.75.75 0 1 1 1.06 1.06l-7.5 7.5Z" clipRule="evenodd" />
</svg>
</button>
)}
{/* Cancel Button */}
<button
onClick={() => onCancel(item.jobId || item.key)}
className="p-1.5 hover:bg-red-900/30 rounded transition-colors"
title="Hủy tải xuống"
disabled={!item.jobId}
>
<TrashIcon className="w-4 h-4 text-red-400" />
</button>
</div>
</div>
{/* Path Flow: Source → NAS Path */}
{(sourcePath || destinationPath) && (
<div className="flex items-start gap-3 mb-3">
{/* Source Path (Lezhin Disk or Sharing Link) */}
{sourcePath && (
<div className="flex-1 min-w-0">
<CopyButtonWithModal
label={sourceLabel}
text={sourcePath}
variant="blue"
/>
</div>
)}
{/* Arrow Icon */}
{sourcePath && destinationPath && (
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 text-slate-500 flex-shrink-0 mt-2">
<path fillRule="evenodd" d="M16.28 11.47a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 0 1-1.06-1.06L14.69 12 7.72 5.03a.75.75 0 0 1 1.06-1.06l7.5 7.5Z" clipRule="evenodd" />
</svg>
)}
{/* NAS Path (Destination) - Show only folder name */}
{destinationPath && (
<div className="flex-1 min-w-0">
<FolderPathDisplay
fullPath={destinationPath}
variant="yellow"
maxChars={60}
/>
</div>
)}
</div>
)}
{/* Files Progress List */}
{hasProgress && isExpanded && item.progressData?.files_status && (
<div className="mt-3 space-y-2 border-t border-slate-700/50 pt-3">
{[...item.progressData.files_status].sort((a, b) => {
// Natural sort by file name
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
}).map((file, idx) => (
<div key={idx} className="bg-slate-900/40 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* File icon */}
<span className="text-base flex-shrink-0">
{file.is_folder ? '📁' : '📄'}
</span>
{/* File name */}
<span className="text-sm text-slate-200 truncate" title={file.name}>
{file.name}
</span>
{/* Status badge */}
<span className={`text-xs px-2 py-0.5 rounded-full flex-shrink-0 ${file.status === 'completed' ? 'bg-green-900/40 text-green-400' :
file.status === 'downloading' ? 'bg-amber-900/40 text-amber-400' :
file.status === 'failed' ? 'bg-red-900/40 text-red-400' :
'bg-slate-700/40 text-slate-400'
}`}>
{file.status === 'completed' ? '✓ Hoàn tất' :
file.status === 'downloading' ? '↓ Đang tải' :
file.status === 'failed' ? '✗ Lỗi' :
'⋯ Chờ'}
</span>
</div>
{/* Size/Progress info */}
<div className="text-xs text-slate-400 flex-shrink-0 ml-2">
{file.status === 'downloading' && file.downloaded !== undefined ? (
// Show: downloaded/total for files with known size, or just downloaded for folders
file.total !== undefined && file.total > 0 ? (
<span className="font-mono">{formatSize(file.downloaded)} / {formatSize(file.total)}</span>
) : (
<span className="font-mono">{formatSize(file.downloaded)}</span>
)
) : file.status === 'completed' && file.size > 0 ? (
<span>{formatSize(file.size)}</span>
) : null}
</div>
</div>
{/* Progress bar (only for files with known size) */}
{file.status === 'downloading' && !file.is_folder && file.progress !== undefined && (
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className="absolute left-0 top-0 h-full bg-gradient-to-r from-amber-500 to-amber-400 transition-all duration-300"
style={{ width: `${file.progress}%` }}
>
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
</div>
</div>
)}
{/* Folder progress bar (indeterminate - full bar with pulse animation) */}
{file.status === 'downloading' && file.is_folder && file.downloaded !== undefined && (
<div className="space-y-1">
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
{/* Full bar with pulse animation for folders */}
<div className="absolute left-0 top-0 h-full w-full bg-gradient-to-r from-amber-500 to-amber-400">
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default memo(DownloadQueueItem);