235 lines
12 KiB
TypeScript
235 lines
12 KiB
TypeScript
|
|
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);
|