ge-tool/components/DownloadHistoryItem.tsx

219 lines
9.6 KiB
TypeScript
Raw Permalink Normal View History

2025-12-10 06:41:43 +00:00
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);