219 lines
9.6 KiB
TypeScript
219 lines
9.6 KiB
TypeScript
|
|
import React, { memo } from 'react';
|
||
|
|
import type { DownloadHistoryEntry } from '../types';
|
||
|
|
import TrashIcon from './TrashIcon';
|
||
|
|
import RetryIcon from './RetryIcon';
|
||
|
|
import CheckIcon from './CheckIcon';
|
||
|
|
import XCircleIcon from './XCircleIcon';
|
||
|
|
import { CopyButtonWithModal } from './CopyButtonWithModal';
|
||
|
|
import { TruncatedPath } from './TruncatedPath';
|
||
|
|
import { FolderPathDisplay } from './FolderPathDisplay';
|
||
|
|
|
||
|
|
// Helper function to format file size (extracted from App.tsx)
|
||
|
|
const formatFileSize = (bytes: number | undefined): string => {
|
||
|
|
if (!bytes || bytes === 0) return '';
|
||
|
|
|
||
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
|
|
let size = bytes;
|
||
|
|
let unitIndex = 0;
|
||
|
|
|
||
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
|
|
size /= 1024;
|
||
|
|
unitIndex++;
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatted = unitIndex === 0 ? size.toString() : size.toFixed(2);
|
||
|
|
return ` (${formatted} ${units[unitIndex]})`;
|
||
|
|
};
|
||
|
|
|
||
|
|
interface DownloadHistoryItemProps {
|
||
|
|
entry: DownloadHistoryEntry;
|
||
|
|
onDelete: (id: string) => void;
|
||
|
|
onRetry: (entry: DownloadHistoryEntry) => void;
|
||
|
|
onErrorClick: (details: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const DownloadHistoryItem: React.FC<DownloadHistoryItemProps> = ({ entry, onDelete, onRetry, onErrorClick }) => {
|
||
|
|
// Use geIdAndLang from entry (already formatted by converter)
|
||
|
|
const geIdAndLang = entry.geIdAndLang || 'N/A';
|
||
|
|
|
||
|
|
// Determine overall status
|
||
|
|
const getStatus = () => {
|
||
|
|
if (entry.successCount === entry.totalFiles) return 'success';
|
||
|
|
if (entry.successCount === 0) return 'error';
|
||
|
|
return 'partial';
|
||
|
|
};
|
||
|
|
|
||
|
|
const status = getStatus();
|
||
|
|
|
||
|
|
// Get status icon and color
|
||
|
|
const getStatusDisplay = () => {
|
||
|
|
switch (status) {
|
||
|
|
case 'success':
|
||
|
|
return {
|
||
|
|
icon: <CheckIcon className="w-5 h-5" />,
|
||
|
|
color: 'text-green-400',
|
||
|
|
bg: 'bg-green-900/5 border-green-700/25',
|
||
|
|
label: 'Hoàn thành',
|
||
|
|
};
|
||
|
|
case 'error':
|
||
|
|
return {
|
||
|
|
icon: <XCircleIcon className="w-5 h-5" />,
|
||
|
|
color: 'text-red-400',
|
||
|
|
bg: 'bg-red-900/30 border-red-700/25',
|
||
|
|
label: 'Thất bại',
|
||
|
|
};
|
||
|
|
case 'partial':
|
||
|
|
return {
|
||
|
|
icon: <span className="text-lg">⚠</span>,
|
||
|
|
color: 'text-yellow-400',
|
||
|
|
bg: 'bg-yellow-900/30 border-yellow-700/25',
|
||
|
|
label: 'Một phần',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const statusDisplay = getStatusDisplay();
|
||
|
|
|
||
|
|
// Get error details for failed files
|
||
|
|
const getErrorDetails = () => {
|
||
|
|
const failedFiles = entry.files.filter((f) => f.status === 'error');
|
||
|
|
if (failedFiles.length === 0) return null;
|
||
|
|
|
||
|
|
return failedFiles
|
||
|
|
.map((f) => `${f.name}: ${f.message}`)
|
||
|
|
.join('\n');
|
||
|
|
};
|
||
|
|
|
||
|
|
const errorDetails = getErrorDetails();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={`border rounded-lg p-4 ${statusDisplay.bg}`}>
|
||
|
|
{/* Header: GE ID, Time, Delete 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>
|
||
|
|
<span className="text-slate-500">•</span>
|
||
|
|
<span className="text-slate-400">{entry.timestamp}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Action Buttons */}
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{errorDetails && (
|
||
|
|
<button
|
||
|
|
onClick={() => onErrorClick(errorDetails)}
|
||
|
|
className="text-red-400 hover:text-red-300 text-sm font-medium"
|
||
|
|
title="Xem chi tiết lỗi"
|
||
|
|
>
|
||
|
|
Chi tiết
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{status !== 'success' && (
|
||
|
|
<button
|
||
|
|
onClick={() => onRetry(entry)}
|
||
|
|
className="p-1.5 hover:bg-slate-700 rounded transition-colors"
|
||
|
|
title="Thử lại"
|
||
|
|
>
|
||
|
|
<RetryIcon className="w-4 h-4 text-slate-400" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<button
|
||
|
|
onClick={() => onDelete(entry.id)}
|
||
|
|
className="p-1.5 hover:bg-red-900/30 rounded transition-colors"
|
||
|
|
title="Xóa"
|
||
|
|
>
|
||
|
|
<TrashIcon className="w-4 h-4 text-red-400" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Path Flow: Lezhin Disk → NAS Path */}
|
||
|
|
<div className="flex items-start gap-3 mb-3">
|
||
|
|
{/* Lezhin Disk Path (Source - only show once) */}
|
||
|
|
{entry.mongoDbPath && (
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<CopyButtonWithModal
|
||
|
|
label="Lezhin Disk"
|
||
|
|
text={entry.mongoDbPath}
|
||
|
|
variant="blue"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Arrow Icon */}
|
||
|
|
{entry.mongoDbPath && entry.files.length > 0 && (
|
||
|
|
<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 (Folder name only - deduplicated) */}
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
{(() => {
|
||
|
|
// Get unique folder names from all files
|
||
|
|
const folderNames = new Set(
|
||
|
|
entry.files
|
||
|
|
.map((file: any) => {
|
||
|
|
const filePath = file.path || '';
|
||
|
|
if (!filePath) return '';
|
||
|
|
|
||
|
|
// Extract folder: D:/.../raw/1000_DE/file.zip → D:/.../raw/1000_DE
|
||
|
|
// Keep original path separators (don't convert \ to /)
|
||
|
|
const cleanPath = filePath.replace(/[/\\]+$/, '');
|
||
|
|
const lastSepIndex = Math.max(
|
||
|
|
cleanPath.lastIndexOf('/'),
|
||
|
|
cleanPath.lastIndexOf('\\')
|
||
|
|
);
|
||
|
|
return lastSepIndex > 0 ? cleanPath.substring(0, lastSepIndex) : cleanPath;
|
||
|
|
})
|
||
|
|
.filter(Boolean)
|
||
|
|
);
|
||
|
|
|
||
|
|
// Display unique folders
|
||
|
|
return Array.from(folderNames).map((folderPath, index) => (
|
||
|
|
<FolderPathDisplay
|
||
|
|
key={index}
|
||
|
|
fullPath={folderPath}
|
||
|
|
variant="yellow"
|
||
|
|
maxChars={60}
|
||
|
|
/>
|
||
|
|
));
|
||
|
|
})()}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Files List */}
|
||
|
|
{entry.files.length > 0 && (
|
||
|
|
<div className="mt-3 pt-3 border-t border-slate-700/50">
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{entry.files.map((file: any, index) => {
|
||
|
|
const isSuccess = file.status === 'success' || file.status === 'completed';
|
||
|
|
const isFailed = file.status === 'failed' || file.status === 'error';
|
||
|
|
const sizeStr = formatFileSize(file.file_size);
|
||
|
|
const is38Bytes = file.file_size === 38; // Detect 38B error file
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={index}
|
||
|
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${is38Bytes
|
||
|
|
? 'bg-red-900/30 text-red-300 border border-red-700/50' // 38B error
|
||
|
|
: isSuccess
|
||
|
|
? 'bg-green-900/20 text-green-300 border border-green-700/30'
|
||
|
|
: isFailed
|
||
|
|
? 'bg-red-900/20 text-red-300 border border-red-700/30'
|
||
|
|
: 'bg-slate-700/20 text-slate-300 border border-slate-700/30'
|
||
|
|
}`}
|
||
|
|
title={isFailed ? (file.error_message || file.message || 'Lỗi không xác định') : undefined}
|
||
|
|
>
|
||
|
|
<span className="overflow-hidden break-words">{file.name}{sizeStr}</span>
|
||
|
|
<span className="flex-shrink-0"></span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default memo(DownloadHistoryItem);
|