Commit 3c1f03f8 authored by BangNSK's avatar BangNSK

feat: implement JobDetail page to display and manage crawl job status, pages, and assets

parent 82aeed08
...@@ -14,6 +14,13 @@ import { ...@@ -14,6 +14,13 @@ import {
Download, Download,
Loader2, Loader2,
Image as ImageIcon, Image as ImageIcon,
Eye,
Search,
Filter,
Copy,
Check,
Code,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress'; import { getJobProgress } from '../utils/jobProgress';
...@@ -51,6 +58,7 @@ interface CrawlJob { ...@@ -51,6 +58,7 @@ interface CrawlJob {
interface CrawlPage { interface CrawlPage {
id: string; id: string;
url: string; url: string;
normalizedUrl?: string;
title: string | null; title: string | null;
description: string | null; description: string | null;
status: string; status: string;
...@@ -61,6 +69,10 @@ interface CrawlPage { ...@@ -61,6 +69,10 @@ interface CrawlPage {
warnings: string[]; warnings: string[];
dataQualityScore: number | null; dataQualityScore: number | null;
wordCount: number | null; wordCount: number | null;
contentHash?: string | null;
rawMarkdown?: string | null;
mainContent?: string | null;
cleanText?: string | null;
} }
interface CrawlExport { interface CrawlExport {
...@@ -94,6 +106,16 @@ export const JobDetail: React.FC = () => { ...@@ -94,6 +106,16 @@ export const JobDetail: React.FC = () => {
const [exports, setExports] = useState<CrawlExport[]>([]); const [exports, setExports] = useState<CrawlExport[]>([]);
const [assets, setAssets] = useState<CrawlAsset[]>([]); const [assets, setAssets] = useState<CrawlAsset[]>([]);
// Filter States for Pages
const [searchQuery, setSearchQuery] = useState('');
const [pageStatusFilter, setPageStatusFilter] = useState('ALL');
const [minQualityFilter, setMinQualityFilter] = useState<number | 'ALL'>('ALL');
// Preview Modal States
const [selectedPage, setSelectedPage] = useState<CrawlPage | null>(null);
const [previewTab, setPreviewTab] = useState<'clean' | 'raw' | 'text'>('clean');
const [copied, setCopied] = useState(false);
const [loadingJob, setLoadingJob] = useState(true); const [loadingJob, setLoadingJob] = useState(true);
const [loadingPages, setLoadingPages] = useState(true); const [loadingPages, setLoadingPages] = useState(true);
const [loadingAssets, setLoadingAssets] = useState(true); const [loadingAssets, setLoadingAssets] = useState(true);
...@@ -131,25 +153,43 @@ export const JobDetail: React.FC = () => { ...@@ -131,25 +153,43 @@ export const JobDetail: React.FC = () => {
} }
}, [id]); }, [id]);
const fetchPages = useCallback(async (page = 1, silent = false) => { const fetchPages = useCallback(
const requestId = ++latestPagesRequestRef.current; async (
try { page = 1,
if (!silent) setLoadingPages(true); silent = false,
const res = await api.get(`/crawl-jobs/${id}/pages`, { params: { page, limit: 20 } }); search = searchQuery,
if (res.data.success && requestId === latestPagesRequestRef.current) { status = pageStatusFilter,
setPages(res.data.data.items || []); minQuality = minQualityFilter
setPagesMeta(res.data.data.meta); ) => {
setPagesError(''); const requestId = ++latestPagesRequestRef.current;
} try {
} catch (e) { if (!silent) setLoadingPages(true);
console.error('Failed to fetch pages', e); const params: Record<string, any> = {
if (requestId === latestPagesRequestRef.current) { page,
setPagesError('Không thể tải danh sách trang đã cào.'); limit: 20,
preview: true,
};
if (search.trim()) params.search = search.trim();
if (status !== 'ALL') params.status = status;
if (minQuality !== 'ALL') params.minQualityScore = minQuality;
const res = await api.get(`/crawl-jobs/${id}/pages`, { params });
if (res.data.success && requestId === latestPagesRequestRef.current) {
setPages(res.data.data.items || []);
setPagesMeta(res.data.data.meta);
setPagesError('');
}
} catch (e) {
console.error('Failed to fetch pages', e);
if (requestId === latestPagesRequestRef.current) {
setPagesError('Không thể tải danh sách trang đã cào.');
}
} finally {
if (!silent && requestId === latestPagesRequestRef.current) setLoadingPages(false);
} }
} finally { },
if (!silent && requestId === latestPagesRequestRef.current) setLoadingPages(false); [id, searchQuery, pageStatusFilter, minQualityFilter]
} );
}, [id]);
const fetchExports = useCallback(async () => { const fetchExports = useCallback(async () => {
try { try {
...@@ -162,34 +202,37 @@ export const JobDetail: React.FC = () => { ...@@ -162,34 +202,37 @@ export const JobDetail: React.FC = () => {
} }
}, [id]); }, [id]);
const fetchAssets = useCallback(async (typeFilter = selectedAssetTypeFilter, silent = false) => { const fetchAssets = useCallback(
const requestId = ++latestAssetsRequestRef.current; async (typeFilter = selectedAssetTypeFilter, silent = false) => {
try { const requestId = ++latestAssetsRequestRef.current;
if (!silent) setLoadingAssets(true); try {
const res = await api.get(`/crawl-jobs/${id}/assets`, { if (!silent) setLoadingAssets(true);
params: { const res = await api.get(`/crawl-jobs/${id}/assets`, {
assetType: typeFilter === 'ALL' ? undefined : typeFilter, params: {
}, assetType: typeFilter === 'ALL' ? undefined : typeFilter,
}); },
if (res.data.success && requestId === latestAssetsRequestRef.current) { });
setAssets(res.data.data || []); if (res.data.success && requestId === latestAssetsRequestRef.current) {
setAssetsError(''); setAssets(res.data.data || []);
} setAssetsError('');
} catch (e) { }
console.error('Failed to fetch assets', e); } catch (e) {
if (requestId === latestAssetsRequestRef.current) { console.error('Failed to fetch assets', e);
setAssetsError('Không thể tải danh sách tài nguyên.'); if (requestId === latestAssetsRequestRef.current) {
setAssetsError('Không thể tải danh sách tài nguyên.');
}
} finally {
if (!silent && requestId === latestAssetsRequestRef.current) setLoadingAssets(false);
} }
} finally { },
if (!silent && requestId === latestAssetsRequestRef.current) setLoadingAssets(false); [id, selectedAssetTypeFilter]
} );
}, [id, selectedAssetTypeFilter]);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
void fetchJob(); void fetchJob();
void fetchPages(); void fetchPages(1);
void fetchExports(); void fetchExports();
void fetchAssets(); void fetchAssets();
}, [fetchExports, fetchJob, fetchPages, fetchAssets, id]); }, [fetchExports, fetchJob, fetchPages, fetchAssets, id]);
...@@ -206,7 +249,7 @@ export const JobDetail: React.FC = () => { ...@@ -206,7 +249,7 @@ export const JobDetail: React.FC = () => {
await Promise.all([ await Promise.all([
fetchJob(), fetchJob(),
fetchPages(pagesMeta.page, true), fetchPages(pagesMeta.page, true),
fetchAssets(selectedAssetTypeFilter, true) fetchAssets(selectedAssetTypeFilter, true),
]); ]);
if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS); if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS);
}; };
...@@ -246,12 +289,8 @@ export const JobDetail: React.FC = () => { ...@@ -246,12 +289,8 @@ export const JobDetail: React.FC = () => {
const handleDownload = async (exportId: string, fileName: string, _mimeType: string) => { const handleDownload = async (exportId: string, fileName: string, _mimeType: string) => {
try { try {
console.log(`Downloading export ${exportId}, requesting blob...`);
const res = await api.get(`/exports/${exportId}/download`, { responseType: 'blob' }); const res = await api.get(`/exports/${exportId}/download`, { responseType: 'blob' });
const blob = res.data;
const blob = res.data; // res.data đã là Blob do responseType: 'blob'
console.log('Downloaded blob type:', blob.type, 'size:', blob.size);
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
...@@ -276,6 +315,12 @@ export const JobDetail: React.FC = () => { ...@@ -276,6 +315,12 @@ export const JobDetail: React.FC = () => {
} }
}; };
const copyToClipboard = (text: string) => {
void navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const getStatusConfig = (status: string) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'COMPLETED': case 'COMPLETED':
...@@ -341,7 +386,7 @@ export const JobDetail: React.FC = () => { ...@@ -341,7 +386,7 @@ export const JobDetail: React.FC = () => {
const statusConfig = getStatusConfig(job.status); const statusConfig = getStatusConfig(job.status);
return ( return (
<div className="space-y-6 max-w-5xl mx-auto"> <div className="space-y-6 max-w-6xl mx-auto">
{/* Back + Header */} {/* Back + Header */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
...@@ -372,7 +417,11 @@ export const JobDetail: React.FC = () => { ...@@ -372,7 +417,11 @@ export const JobDetail: React.FC = () => {
</button> </button>
)} )}
<button <button
onClick={() => { void fetchJob(); void fetchPages(pagesMeta.page); void fetchExports(); }} onClick={() => {
void fetchJob();
void fetchPages(pagesMeta.page);
void fetchExports();
}}
className="p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors" className="p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
title="Làm mới" title="Làm mới"
> >
...@@ -419,13 +468,11 @@ export const JobDetail: React.FC = () => { ...@@ -419,13 +468,11 @@ export const JobDetail: React.FC = () => {
</div> </div>
<div className="w-full bg-slate-100 rounded-full h-3 overflow-hidden"> <div className="w-full bg-slate-100 rounded-full h-3 overflow-hidden">
{isActive && !hasProgressData ? ( {isActive && !hasProgressData ? (
<div className="h-3 w-1/3 animate-pulse rounded-full bg-indigo-500" aria-label="Đang xử lý, chưa có số liệu tiến độ" /> <div className="h-3 w-1/3 animate-pulse rounded-full bg-indigo-500" aria-label="Đang xử lý" />
) : ( ) : (
<div <div
className={`h-3 rounded-full transition-all duration-500 ${ className={`h-3 rounded-full transition-all duration-500 ${
job.status === 'FAILED' ? 'bg-rose-500' : job.status === 'FAILED' ? 'bg-rose-500' : job.status === 'CANCELED' ? 'bg-slate-400' : 'bg-indigo-500'
job.status === 'CANCELED' ? 'bg-slate-400' :
'bg-indigo-500'
} ${isActive ? 'animate-pulse' : ''}`} } ${isActive ? 'animate-pulse' : ''}`}
style={{ width: `${progress.percent}%` }} style={{ width: `${progress.percent}%` }}
/> />
...@@ -450,7 +497,7 @@ export const JobDetail: React.FC = () => { ...@@ -450,7 +497,7 @@ export const JobDetail: React.FC = () => {
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6"> <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<h2 className="text-base font-bold text-slate-900 mb-4 flex items-center gap-2"> <h2 className="text-base font-bold text-slate-900 mb-4 flex items-center gap-2">
<FileText className="h-5 w-5 text-slate-400" /> <FileText className="h-5 w-5 text-slate-400" />
Xuất kết quả dữ liệu Xuất kết quả dữ liệu (Data Contract v1)
</h2> </h2>
{isCrawler && ( {isCrawler && (
...@@ -554,6 +601,66 @@ export const JobDetail: React.FC = () => { ...@@ -554,6 +601,66 @@ export const JobDetail: React.FC = () => {
{activeTab === 'pages' ? ( {activeTab === 'pages' ? (
<> <>
{/* Filter Bar */}
<div className="p-4 bg-slate-50/80 border-b border-slate-100 flex flex-col md:flex-row gap-3 items-center justify-between">
<div className="relative w-full md:w-72">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Tìm URL hoặc tiêu đề..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void fetchPages(1);
}}
className="w-full pl-9 pr-3 py-1.5 text-xs bg-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="flex flex-wrap items-center gap-2 w-full md:w-auto">
<div className="flex items-center gap-1.5 text-xs text-slate-500 font-medium">
<Filter className="h-3.5 w-3.5" /> Lọc:
</div>
<select
value={pageStatusFilter}
onChange={(e) => {
setPageStatusFilter(e.target.value);
void fetchPages(1, false, searchQuery, e.target.value, minQualityFilter);
}}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-700 focus:outline-none"
>
<option value="ALL">Tất cả trạng thái</option>
<option value="SUCCESS">Thành công (SUCCESS)</option>
<option value="FAILED">Thất bại (FAILED)</option>
<option value="BLOCKED">Bị chặn (BLOCKED)</option>
<option value="PENDING">Chờ (PENDING)</option>
</select>
<select
value={minQualityFilter}
onChange={(e) => {
const val = e.target.value === 'ALL' ? 'ALL' : Number(e.target.value);
setMinQualityFilter(val);
void fetchPages(1, false, searchQuery, pageStatusFilter, val);
}}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-700 focus:outline-none"
>
<option value="ALL">Tất cả chất lượng</option>
<option value="70">Điểm Quality &ge; 70 (Tốt)</option>
<option value="40">Điểm Quality &ge; 40 (Trung bình)</option>
<option value="0">Tất cả điểm quality</option>
</select>
<button
type="button"
onClick={() => void fetchPages(1)}
className="px-3 py-1 bg-indigo-600 text-white rounded-lg text-xs font-semibold hover:bg-indigo-700 transition-colors"
>
Áp dụng
</button>
</div>
</div>
{pagesError && ( {pagesError && (
<div className="mx-6 mt-4 flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert"> <div className="mx-6 mt-4 flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{pagesError}</span> <span>{pagesError}</span>
...@@ -567,7 +674,7 @@ export const JobDetail: React.FC = () => { ...@@ -567,7 +674,7 @@ export const JobDetail: React.FC = () => {
</div> </div>
) : pages.length === 0 ? ( ) : pages.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm"> <div className="text-center py-10 text-slate-400 text-sm">
{isActive ? 'Đang chờ Worker bắt đầu cào...' : 'Không có trang nào.'} {isActive ? 'Đang chờ Worker bắt đầu cào...' : 'Không tìm thấy trang phù hợp.'}
</div> </div>
) : ( ) : (
<> <>
...@@ -581,7 +688,7 @@ export const JobDetail: React.FC = () => { ...@@ -581,7 +688,7 @@ export const JobDetail: React.FC = () => {
<th className="px-6 py-3">HTTP Code</th> <th className="px-6 py-3">HTTP Code</th>
<th className="px-6 py-3">Chất lượng</th> <th className="px-6 py-3">Chất lượng</th>
<th className="px-6 py-3">Cảnh báo</th> <th className="px-6 py-3">Cảnh báo</th>
<th className="px-6 py-3">Thời gian cào</th> <th className="px-6 py-3 text-right">Xem trước</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 text-slate-700"> <tbody className="divide-y divide-slate-100 text-slate-700">
...@@ -612,7 +719,7 @@ export const JobDetail: React.FC = () => { ...@@ -612,7 +719,7 @@ export const JobDetail: React.FC = () => {
) : '–'} ) : '–'}
</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3">
<div className="flex flex-col gap-1 items-center"> <div className="flex flex-col gap-1 items-start">
{page.dataQualityScore !== null && page.dataQualityScore !== undefined ? ( {page.dataQualityScore !== null && page.dataQualityScore !== undefined ? (
<span <span
title={`Điểm chất lượng: ${page.dataQualityScore}/100`} title={`Điểm chất lượng: ${page.dataQualityScore}/100`}
...@@ -631,7 +738,7 @@ export const JobDetail: React.FC = () => { ...@@ -631,7 +738,7 @@ export const JobDetail: React.FC = () => {
)} )}
{page.hasSensitiveData && ( {page.hasSensitiveData && (
<span <span
title="Trang này chứa dữ liệu nhạy cảm (email/SĐT/CCCD...)" title="Trang này chứa dữ liệu nhạy cảm"
className="inline-flex items-center gap-0.5 text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-200 px-1.5 py-0.5 rounded" className="inline-flex items-center gap-0.5 text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-200 px-1.5 py-0.5 rounded"
> >
🔒 Nhạy cảm 🔒 Nhạy cảm
...@@ -655,8 +762,16 @@ export const JobDetail: React.FC = () => { ...@@ -655,8 +762,16 @@ export const JobDetail: React.FC = () => {
<span className="text-slate-300 text-xs"></span> <span className="text-slate-300 text-xs"></span>
)} )}
</td> </td>
<td className="px-6 py-3 text-slate-400 text-xs"> <td className="px-6 py-3 text-right">
{page.crawledAt ? new Date(page.crawledAt).toLocaleString('vi-VN') : '–'} <button
onClick={() => {
setSelectedPage(page);
setPreviewTab('clean');
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-900 bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1.5 rounded-lg border border-indigo-100 transition-colors"
>
<Eye className="h-3.5 w-3.5" /> Xem
</button>
</td> </td>
</tr> </tr>
))} ))}
...@@ -676,14 +791,14 @@ export const JobDetail: React.FC = () => { ...@@ -676,14 +791,14 @@ export const JobDetail: React.FC = () => {
disabled={pagesMeta.page <= 1} disabled={pagesMeta.page <= 1}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors" className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
> >
Trước &larr; Trước
</button> </button>
<button <button
onClick={() => fetchPages(pagesMeta.page + 1)} onClick={() => fetchPages(pagesMeta.page + 1)}
disabled={pagesMeta.page >= pagesMeta.totalPages} disabled={pagesMeta.page >= pagesMeta.totalPages}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors" className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
> >
Tiếp Tiếp &rarr;
</button> </button>
</div> </div>
</div> </div>
...@@ -795,13 +910,13 @@ export const JobDetail: React.FC = () => { ...@@ -795,13 +910,13 @@ export const JobDetail: React.FC = () => {
{asset.sourceUrl} {asset.sourceUrl}
</a> </a>
) : ( ) : (
<span className="text-slate-300"></span> <span className="text-slate-300">&mdash;</span>
)} )}
</td> </td>
<td className="px-6 py-3 text-xs text-slate-500 max-w-xs"> <td className="px-6 py-3 text-xs text-slate-500 max-w-xs">
{asset.altText && <p className="truncate" title={`Alt: ${asset.altText}`}><strong>Alt:</strong> {asset.altText}</p>} {asset.altText && <p className="truncate" title={`Alt: ${asset.altText}`}><strong>Alt:</strong> {asset.altText}</p>}
{asset.mimeType && <p className="font-mono text-slate-400"><strong>Mime:</strong> {asset.mimeType}</p>} {asset.mimeType && <p className="font-mono text-slate-400"><strong>Mime:</strong> {asset.mimeType}</p>}
{!asset.altText && !asset.mimeType && <span className="text-slate-300"></span>} {!asset.altText && !asset.mimeType && <span className="text-slate-300">&mdash;</span>}
</td> </td>
</tr> </tr>
))} ))}
...@@ -812,6 +927,161 @@ export const JobDetail: React.FC = () => { ...@@ -812,6 +927,161 @@ export const JobDetail: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{/* Page Preview Modal (Data Contract v1) */}
{selectedPage && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-start justify-between bg-slate-50/50">
<div className="pr-4 min-w-0">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-indigo-50 text-indigo-700 text-xs font-bold rounded">
Data Contract v1 Preview
</span>
{selectedPage.dataQualityScore !== null && selectedPage.dataQualityScore !== undefined && (
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 text-xs font-bold rounded-full">
Quality: {selectedPage.dataQualityScore}/100
</span>
)}
</div>
<h3 className="text-lg font-bold text-slate-900 mt-1 truncate" title={selectedPage.title || selectedPage.url}>
{selectedPage.title || selectedPage.url}
</h3>
<a
href={selectedPage.url}
target="_blank"
rel="noreferrer"
className="text-xs text-indigo-600 hover:underline font-mono truncate block"
>
{selectedPage.url}
</a>
</div>
<button
onClick={() => setSelectedPage(null)}
className="text-slate-400 hover:text-slate-600 p-1"
>
<XCircle className="h-6 w-6" />
</button>
</div>
{/* Content Tabs Header */}
<div className="px-6 py-2 bg-slate-100/70 border-b border-slate-200 flex items-center justify-between">
<div className="flex space-x-2">
<button
onClick={() => setPreviewTab('clean')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'clean'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Sparkles className="h-3.5 w-3.5" />
Main Content (AI Clean)
</button>
<button
onClick={() => setPreviewTab('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'raw'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Code className="h-3.5 w-3.5" />
Raw Markdown
</button>
<button
onClick={() => setPreviewTab('text')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'text'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<FileText className="h-3.5 w-3.5" />
Clean Text
</button>
</div>
<button
onClick={() => {
const content =
previewTab === 'clean'
? selectedPage.mainContent
: previewTab === 'raw'
? selectedPage.rawMarkdown
: selectedPage.cleanText;
if (content) copyToClipboard(content);
}}
className="flex items-center gap-1 px-3 py-1 bg-white border border-slate-300 rounded-md text-xs font-medium text-slate-700 hover:bg-slate-50"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5 text-slate-400" />}
{copied ? 'Đã chép' : 'Sao chép'}
</button>
</div>
{/* Modal Body */}
<div className="p-6 overflow-y-auto flex-1 space-y-4 font-sans">
{/* Meta Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 bg-slate-50 rounded-xl text-xs">
<div>
<span className="text-slate-400 font-medium block">Số từ (Word Count)</span>
<span className="font-semibold text-slate-800">{selectedPage.wordCount ?? '—'}</span>
</div>
<div>
<span className="text-slate-400 font-medium block">Content Hash</span>
<span className="font-mono text-slate-600 truncate block" title={selectedPage.contentHash || ''}>
{selectedPage.contentHash ? `${selectedPage.contentHash.substring(0, 12)}...` : '—'}
</span>
</div>
<div>
<span className="text-slate-400 font-medium block">Cảnh báo</span>
<span className="font-semibold text-slate-800">
{selectedPage.warnings && selectedPage.warnings.length > 0
? selectedPage.warnings.join(', ')
: 'Không có'}
</span>
</div>
<div>
<span className="text-slate-400 font-medium block">Thời gian cào</span>
<span className="text-slate-700">
{selectedPage.crawledAt ? new Date(selectedPage.crawledAt).toLocaleString('vi-VN') : '—'}
</span>
</div>
</div>
{/* Main Content Area */}
<div className="border border-slate-200 rounded-xl p-4 bg-slate-900 text-slate-100 font-mono text-xs overflow-x-auto min-h-[300px] max-h-[500px]">
{previewTab === 'clean' && (
<pre className="whitespace-pre-wrap font-mono break-words leading-relaxed text-emerald-400">
{selectedPage.mainContent || <span className="text-slate-500 italic">Chưa có nội dung mainContent.</span>}
</pre>
)}
{previewTab === 'raw' && (
<pre className="whitespace-pre-wrap font-mono break-words leading-relaxed text-slate-200">
{selectedPage.rawMarkdown || <span className="text-slate-500 italic">Chưa có nội dung rawMarkdown.</span>}
</pre>
)}
{previewTab === 'text' && (
<pre className="whitespace-pre-wrap font-sans break-words leading-relaxed text-slate-300">
{selectedPage.cleanText || <span className="text-slate-500 italic">Chưa có nội dung cleanText.</span>}
</pre>
)}
</div>
</div>
{/* Modal Footer */}
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex justify-end">
<button
onClick={() => setSelectedPage(null)}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 text-xs font-semibold transition-colors"
>
Đóng
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment