Update
This commit is contained in:
295
resources/js/pages/admin/duplicate-management.tsx
Normal file
295
resources/js/pages/admin/duplicate-management.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user