296 lines
15 KiB
TypeScript
296 lines
15 KiB
TypeScript
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useAxios } from '@/plugins/AxiosContext';
|
|
import { Head, usePage } from '@inertiajs/react';
|
|
import { AlertCircle, Loader2, RotateCw, Search, Trash2 } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
interface DuplicateItem {
|
|
id: number;
|
|
name: string;
|
|
distance: number;
|
|
url: string;
|
|
group: number;
|
|
}
|
|
|
|
interface DuplicateGroup {
|
|
original: {
|
|
id: number;
|
|
name: string;
|
|
url: string;
|
|
group: number;
|
|
} | null;
|
|
duplicates: DuplicateItem[];
|
|
}
|
|
|
|
interface ScanResult {
|
|
duplicates: DuplicateGroup[];
|
|
total_groups: number;
|
|
total_duplicates: number;
|
|
}
|
|
|
|
export default function DuplicateManagement() {
|
|
const { title } = usePage().props;
|
|
const axios = useAxios();
|
|
|
|
const [threshold, setThreshold] = useState(5);
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
|
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
|
|
|
const handleScan = async () => {
|
|
setIsScanning(true);
|
|
try {
|
|
const response = await axios.post(route('admin.duplicates.scan'), {
|
|
threshold,
|
|
});
|
|
|
|
if (response.data.duplicates.length === 0) {
|
|
toast.info('No duplicate images were found with the current threshold.');
|
|
}
|
|
|
|
setScanResult(response.data);
|
|
} catch (error) {
|
|
console.error('Scan error:', error);
|
|
toast.error('Failed to scan for duplicates');
|
|
} finally {
|
|
setIsScanning(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
setDeletingIds((prev) => [...prev, id]);
|
|
try {
|
|
const response = await axios.post(route('admin.duplicates.delete'), {
|
|
id,
|
|
});
|
|
|
|
if (response.data.success) {
|
|
toast.success(response.data.message);
|
|
|
|
// Remove the deleted item from results
|
|
setScanResult((prev) => {
|
|
if (!prev) return null;
|
|
|
|
return {
|
|
...prev,
|
|
duplicates: prev.duplicates.map((group) => {
|
|
// If the original was deleted, remove it from the group
|
|
if (group.original && group.original.id === id) {
|
|
return {
|
|
...group,
|
|
original: null, // Mark original as deleted
|
|
};
|
|
}
|
|
|
|
// Remove the deleted item from duplicates
|
|
return {
|
|
...group,
|
|
duplicates: group.duplicates.filter((item) => item.id !== id),
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
if (error.response?.data?.message) {
|
|
toast.error(error.response.data.message);
|
|
} else {
|
|
toast.error('Failed to delete image');
|
|
}
|
|
} finally {
|
|
setDeletingIds((prev) => prev.filter((deletingId) => deletingId !== id));
|
|
}
|
|
};
|
|
|
|
const handleRegenerateHash = async (id: number) => {
|
|
try {
|
|
const response = await axios.post(route('admin.duplicates.regenerate-hash'), {
|
|
id,
|
|
});
|
|
|
|
if (response.data.success) {
|
|
toast.success(response.data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('Regenerate hash error:', error);
|
|
toast.error('Failed to regenerate hash');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Head title={title} />
|
|
|
|
<div className="mx-auto max-w-7xl p-6">
|
|
<div className="mb-8">
|
|
<h1 className="mb-2 text-3xl font-bold">Duplicate Management</h1>
|
|
<p className="text-gray-600">Find and manage duplicate meme images using Hamming distance comparison</p>
|
|
</div>
|
|
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Scan for Duplicates</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-1 items-center">
|
|
<Label htmlFor="threshold">Hamming Distance Threshold</Label>
|
|
<Input
|
|
id="threshold"
|
|
type="text"
|
|
value={threshold}
|
|
onChange={(e) => setThreshold(Number(e.target.value))}
|
|
min="0"
|
|
max="64"
|
|
className="mt-1"
|
|
/>
|
|
<p className="mt-1 text-sm text-gray-500">Lower values = stricter matching (0-10 recommended)</p>
|
|
</div>
|
|
<Button onClick={handleScan} disabled={isScanning}>
|
|
{isScanning ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Scanning...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Search className="mr-2 h-4 w-4" />
|
|
Scan for Duplicates
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{scanResult && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<AlertCircle className="h-5 w-5" />
|
|
Scan Results
|
|
</CardTitle>
|
|
<div className="flex gap-4 text-sm text-gray-600">
|
|
<span>Groups: {scanResult.total_groups}</span>
|
|
<span>Total Duplicates: {scanResult.total_duplicates}</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{scanResult.duplicates.length === 0 ? (
|
|
<p className="py-8 text-center text-gray-500">No duplicates found</p>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{scanResult.duplicates.map((group, index) => (
|
|
<div key={index} className="rounded-lg border p-4">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">Duplicate Group {index + 1}</h3>
|
|
<Badge variant="outline">{group.duplicates.length} duplicates</Badge>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{/* Original */}
|
|
{group.original && (
|
|
<div className="relative rounded-lg border-2 border-blue-200 p-3">
|
|
<div className="absolute top-2 right-2 flex flex-col gap-1">
|
|
<Badge variant="default">Original</Badge>
|
|
<Badge variant={group.original.group === 2 ? 'default' : 'secondary'}>
|
|
Group {group.original.group}
|
|
</Badge>
|
|
</div>
|
|
<img
|
|
src={group.original.url}
|
|
alt={group.original.name}
|
|
className="mb-2 h-32 w-full rounded object-cover"
|
|
/>
|
|
<p className="mb-1 text-sm font-medium">{group.original.name}</p>
|
|
<p className="mb-2 text-xs text-gray-500">ID: {group.original.id}</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRegenerateHash(group.original.id)}
|
|
className="flex-1"
|
|
>
|
|
<RotateCw className="mr-1 h-3 w-3" />
|
|
Regenerate
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleDelete(group.original.id)}
|
|
disabled={deletingIds.includes(group.original.id)}
|
|
className="flex-1"
|
|
>
|
|
{deletingIds.includes(group.original.id) ? (
|
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-1 h-3 w-3" />
|
|
)}
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Duplicates */}
|
|
{group.duplicates.map((duplicate) => (
|
|
<div key={duplicate.id} className="relative rounded-lg border p-3">
|
|
<div className="absolute top-2 right-2 flex flex-col gap-1">
|
|
<Badge variant="secondary">Distance: {duplicate.distance}</Badge>
|
|
<Badge variant={duplicate.group === 2 ? 'default' : 'secondary'}>
|
|
Group {duplicate.group}
|
|
</Badge>
|
|
</div>
|
|
<img
|
|
src={duplicate.url}
|
|
alt={duplicate.name}
|
|
className="mb-2 h-32 w-full rounded object-cover"
|
|
/>
|
|
<p className="mb-1 text-sm font-medium">{duplicate.name}</p>
|
|
<p className="mb-2 text-xs text-gray-500">ID: {duplicate.id}</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRegenerateHash(duplicate.id)}
|
|
className="flex-1"
|
|
>
|
|
<RotateCw className="mr-1 h-3 w-3" />
|
|
Regenerate
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleDelete(duplicate.id)}
|
|
disabled={deletingIds.includes(duplicate.id)}
|
|
className="flex-1"
|
|
>
|
|
{deletingIds.includes(duplicate.id) ? (
|
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-1 h-3 w-3" />
|
|
)}
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|