import { useState, useEffect, useCallback } from 'react'; import { DuplicateData, DuplicateGroup } from '../types'; import { formatNumber, formatMs, getFilename } from '../utils/format'; function Duplicates() { const [duplicateData, setDuplicateData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<'hash' | 'title'>('hash'); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const fetchDuplicates = useCallback(async () => { try { const res = await fetch('/api/duplicates'); if (res.ok) { const data = await res.json(); setDuplicateData(data); setError(null); } else { setError('Failed to fetch duplicate data'); } } catch (err) { console.error('Failed to fetch duplicates:', err); setError('Failed to fetch duplicate data'); } }, []); const computeDuplicates = async () => { setLoading(true); try { const res = await fetch('/api/duplicates/compute', { method: 'POST' }); if (res.ok) { await fetchDuplicates(); } else { setError('Failed to compute duplicates'); } } catch (err) { console.error('Failed to compute duplicates:', err); setError('Failed to compute duplicates'); } finally { setLoading(false); } }; useEffect(() => { fetchDuplicates(); const interval = setInterval(fetchDuplicates, 30000); // Refresh every 30s return () => clearInterval(interval); }, [fetchDuplicates]); const toggleGroup = (key: string) => { setExpandedGroups(prev => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); }; const expandAll = () => { const groups = activeTab === 'hash' ? duplicateData?.hashDuplicates : duplicateData?.titleDuplicates; if (groups) { setExpandedGroups(new Set(groups.map(g => g.key))); } }; const collapseAll = () => { setExpandedGroups(new Set()); }; const renderDuplicateGroup = (group: DuplicateGroup, type: 'hash' | 'title') => { const isExpanded = expandedGroups.has(group.key); const displayKey = type === 'hash' ? `${group.key.substring(0, 16)}...` : group.key; return (
toggleGroup(group.key)} > {isExpanded ? '▼' : '▶'} {displayKey} {group.files.length} files
{isExpanded && (
{group.files.map((file, idx) => (
{idx + 1}. {getFilename(file)} {file}
))}
)}
); }; const hashDuplicates = duplicateData?.hashDuplicates || []; const titleDuplicates = duplicateData?.titleDuplicates || []; const stats = duplicateData?.stats; return (

Duplicate Analysis

{error && (
{error}
)} {/* Stats Cards */} {stats && (
#
{formatNumber(stats.hashGroupCount)}
Hash Duplicate Groups
{formatNumber(stats.hashFileCount)} total files
T
{formatNumber(stats.titleGroupCount)}
Title Duplicate Groups
{formatNumber(stats.titleFileCount)} total files
{duplicateData?.computedAt && (
i
{formatMs(duplicateData.computationTimeMs)}
Computation Time
{new Date(duplicateData.computedAt).toLocaleString()}
)}
)} {/* Tabs */}
{activeTab === 'hash' && (
{hashDuplicates.length === 0 ? (

No hash duplicates found

Files with identical content (same SHA-256 hash)

) : ( <>

Files with identical content (exact byte-for-byte matches)

{hashDuplicates.map(group => renderDuplicateGroup(group, 'hash'))} )}
)} {activeTab === 'title' && (
{titleDuplicates.length === 0 ? (

No title duplicates found

Files with similar parsed titles

) : ( <>

Files with matching parsed titles (may be different quality/releases)

{titleDuplicates.map(group => renderDuplicateGroup(group, 'title'))} )}
)}
); } export default Duplicates;