|
@@ -0,0 +1,576 @@
|
|
|
|
|
+"use client";
|
|
|
|
|
+
|
|
|
|
|
+import {
|
|
|
|
|
+ ArrowPathIcon,
|
|
|
|
|
+ CheckCircleIcon,
|
|
|
|
|
+ EyeSlashIcon,
|
|
|
|
|
+ Squares2X2Icon,
|
|
|
|
|
+ TrashIcon
|
|
|
|
|
+} from "@heroicons/react/24/outline";
|
|
|
|
|
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
+import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
+import toast from "react-hot-toast";
|
|
|
|
|
+import { get, post } from "../../lib/api";
|
|
|
|
|
+import ConfirmationDialog from "../components/ConfirmationDialog";
|
|
|
|
|
+import LoadingCard from "../components/Loading";
|
|
|
|
|
+import { useNotifications } from "../components/NotificationContext";
|
|
|
|
|
+import { useAppContext } from "../providers/AppContext";
|
|
|
|
|
+
|
|
|
|
|
+interface DuplicateGroup {
|
|
|
|
|
+ id: number;
|
|
|
|
|
+ dataset: string;
|
|
|
|
|
+ destination: string;
|
|
|
|
|
+ hash: string;
|
|
|
|
|
+ size: number;
|
|
|
|
|
+ files: string[];
|
|
|
|
|
+ status: string;
|
|
|
|
|
+ created_at?: string;
|
|
|
|
|
+ reviewed_at?: string;
|
|
|
|
|
+ note?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type SortField = "dataset" | "count" | "size" | "created_at";
|
|
|
|
|
+
|
|
|
|
|
+type DeleteSelection = {
|
|
|
|
|
+ isOpen: boolean;
|
|
|
|
|
+ groups: Array<{ id: number; files: string[] }>;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function formatBytes(bytes: number) {
|
|
|
|
|
+ if (!bytes) return "0 B";
|
|
|
|
|
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
|
|
|
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
|
|
|
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function makeFileKey(groupId: number, path: string) {
|
|
|
|
|
+ return `${groupId}::${encodeURIComponent(path)}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseFileKey(key: string) {
|
|
|
|
|
+ const [idStr, encodedPath] = key.split("::");
|
|
|
|
|
+ return { groupId: Number(idStr), path: decodeURIComponent(encodedPath) };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function DuplicateList() {
|
|
|
|
|
+ const queryClient = useQueryClient();
|
|
|
|
|
+ const { datasets } = useAppContext();
|
|
|
|
|
+ const { addNotification } = useNotifications();
|
|
|
|
|
+
|
|
|
|
|
+ const {
|
|
|
|
|
+ data: duplicateGroups,
|
|
|
|
|
+ isLoading,
|
|
|
|
|
+ error,
|
|
|
|
|
+ refetch
|
|
|
|
|
+ } = useQuery<DuplicateGroup[]>({
|
|
|
|
|
+ queryKey: ["duplicate-files"],
|
|
|
|
|
+ queryFn: async () => get("/maintenance/duplicates?status=pending")
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const [enabledDatasets, setEnabledDatasets] = useState<Set<string>>(
|
|
|
|
|
+ new Set()
|
|
|
|
|
+ );
|
|
|
|
|
+ const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
|
+ const [sortField, setSortField] = useState<SortField>("count");
|
|
|
|
|
+ const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
|
|
|
|
+ const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
|
|
|
|
|
+ const [selectedGroups, setSelectedGroups] = useState<Set<number>>(new Set());
|
|
|
|
|
+ const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
|
|
|
|
+ const [deleteSelection, setDeleteSelection] = useState<DeleteSelection>({
|
|
|
|
|
+ isOpen: false,
|
|
|
|
|
+ groups: []
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Initialize enabled datasets on first load
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (datasets && datasets.length > 0 && enabledDatasets.size === 0) {
|
|
|
|
|
+ const names = datasets
|
|
|
|
|
+ .map((p: string) => p.split("/").pop())
|
|
|
|
|
+ .filter(Boolean) as string[];
|
|
|
|
|
+ setEnabledDatasets(new Set(names));
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [datasets]);
|
|
|
|
|
+
|
|
|
|
|
+ const scanMutation = useMutation({
|
|
|
|
|
+ mutationFn: () =>
|
|
|
|
|
+ post("/maintenance/duplicates/scan", { resetExisting: false }),
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ toast.success("Duplicate scan completed");
|
|
|
|
|
+ addNotification({
|
|
|
|
|
+ type: "info",
|
|
|
|
|
+ title: "Scan Finished",
|
|
|
|
|
+ message: "Duplicate scan completed. Refreshing results."
|
|
|
|
|
+ });
|
|
|
|
|
+ refetch();
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: any) => {
|
|
|
|
|
+ console.error(err);
|
|
|
|
|
+ toast.error("Failed to start duplicate scan");
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const markNotDuplicateMutation = useMutation({
|
|
|
|
|
+ mutationFn: async (ids: number[]) => {
|
|
|
|
|
+ await Promise.all(
|
|
|
|
|
+ ids.map((id) =>
|
|
|
|
|
+ post(`/maintenance/duplicates/${id}/mark`, {
|
|
|
|
|
+ status: "reviewed",
|
|
|
|
|
+ note: "not_duplicate"
|
|
|
|
|
+ })
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ toast.success("Marked as not duplicate");
|
|
|
|
|
+ setSelectedGroups(new Set());
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["duplicate-files"] });
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: any) => {
|
|
|
|
|
+ console.error(err);
|
|
|
|
|
+ toast.error("Failed to update duplicates");
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const deleteMutation = useMutation({
|
|
|
|
|
+ mutationFn: async (groups: Array<{ id: number; files: string[] }>) => {
|
|
|
|
|
+ if (!groups.length) return;
|
|
|
|
|
+ await Promise.all(
|
|
|
|
|
+ groups.map((group) =>
|
|
|
|
|
+ post(`/maintenance/duplicates/${group.id}/purge`, {
|
|
|
|
|
+ files: group.files
|
|
|
|
|
+ })
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ toast.success("Selected files deleted");
|
|
|
|
|
+ setSelectedFiles(new Set());
|
|
|
|
|
+ setSelectedGroups(new Set());
|
|
|
|
|
+ setDeleteSelection({ isOpen: false, groups: [] });
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["duplicate-files"] });
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err: any) => {
|
|
|
|
|
+ console.error(err);
|
|
|
|
|
+ toast.error("Failed to delete selected files");
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const filteredData = useMemo(() => {
|
|
|
|
|
+ if (!duplicateGroups) return [] as DuplicateGroup[];
|
|
|
|
|
+
|
|
|
|
|
+ return duplicateGroups
|
|
|
|
|
+ .filter((group) => enabledDatasets.has(group.dataset))
|
|
|
|
|
+ .filter((group) => {
|
|
|
|
|
+ if (!searchTerm) return true;
|
|
|
|
|
+ const term = searchTerm.toLowerCase();
|
|
|
|
|
+ const inFiles = group.files.some((f) => f.toLowerCase().includes(term));
|
|
|
|
|
+ const inDest = group.destination?.toLowerCase().includes(term);
|
|
|
|
|
+ return inFiles || inDest || group.hash.toLowerCase().includes(term);
|
|
|
|
|
+ })
|
|
|
|
|
+ .sort((a, b) => {
|
|
|
|
|
+ let aVal: number | string = 0;
|
|
|
|
|
+ let bVal: number | string = 0;
|
|
|
|
|
+
|
|
|
|
|
+ switch (sortField) {
|
|
|
|
|
+ case "dataset":
|
|
|
|
|
+ aVal = a.dataset;
|
|
|
|
|
+ bVal = b.dataset;
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "count":
|
|
|
|
|
+ aVal = a.files.length;
|
|
|
|
|
+ bVal = b.files.length;
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "size":
|
|
|
|
|
+ aVal = a.size;
|
|
|
|
|
+ bVal = b.size;
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "created_at":
|
|
|
|
|
+ aVal = a.created_at || "";
|
|
|
|
|
+ bVal = b.created_at || "";
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
|
|
|
|
|
+ if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [duplicateGroups, enabledDatasets, searchTerm, sortField, sortDirection]);
|
|
|
|
|
+
|
|
|
|
|
+ const toggleDataset = (dataset: string) => {
|
|
|
|
|
+ const next = new Set(enabledDatasets);
|
|
|
|
|
+ if (next.has(dataset)) next.delete(dataset);
|
|
|
|
|
+ else next.add(dataset);
|
|
|
|
|
+ setEnabledDatasets(next);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleExpanded = (id: number) => {
|
|
|
|
|
+ const next = new Set(expandedRows);
|
|
|
|
|
+ if (next.has(id)) next.delete(id);
|
|
|
|
|
+ else next.add(id);
|
|
|
|
|
+ setExpandedRows(next);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleGroupSelection = (id: number) => {
|
|
|
|
|
+ const next = new Set(selectedGroups);
|
|
|
|
|
+ if (next.has(id)) next.delete(id);
|
|
|
|
|
+ else next.add(id);
|
|
|
|
|
+ setSelectedGroups(next);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleFileSelection = (groupId: number, filePath: string) => {
|
|
|
|
|
+ const key = makeFileKey(groupId, filePath);
|
|
|
|
|
+ const next = new Set(selectedFiles);
|
|
|
|
|
+ if (next.has(key)) next.delete(key);
|
|
|
|
|
+ else next.add(key);
|
|
|
|
|
+ setSelectedFiles(next);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const selectGroupFiles = (group: DuplicateGroup, checked: boolean) => {
|
|
|
|
|
+ const next = new Set(selectedFiles);
|
|
|
|
|
+ for (const file of group.files) {
|
|
|
|
|
+ const key = makeFileKey(group.id, file);
|
|
|
|
|
+ if (checked) next.add(key);
|
|
|
|
|
+ else next.delete(key);
|
|
|
|
|
+ }
|
|
|
|
|
+ setSelectedFiles(next);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleBatchDelete = () => {
|
|
|
|
|
+ if (!duplicateGroups) return;
|
|
|
|
|
+ const grouped = new Map<number, string[]>();
|
|
|
|
|
+
|
|
|
|
|
+ selectedFiles.forEach((key) => {
|
|
|
|
|
+ const { groupId, path } = parseFileKey(key);
|
|
|
|
|
+ const arr = grouped.get(groupId) || [];
|
|
|
|
|
+ arr.push(path);
|
|
|
|
|
+ grouped.set(groupId, arr);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (grouped.size === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ const groups = Array.from(grouped.entries()).map(([id, files]) => ({
|
|
|
|
|
+ id,
|
|
|
|
|
+ files
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ setDeleteSelection({ isOpen: true, groups });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const confirmDelete = () => {
|
|
|
|
|
+ deleteMutation.mutate(deleteSelection.groups);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const markSelectedNotDuplicate = () => {
|
|
|
|
|
+ if (selectedGroups.size === 0) return;
|
|
|
|
|
+ markNotDuplicateMutation.mutate(Array.from(selectedGroups));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (isLoading && !duplicateGroups) {
|
|
|
|
|
+ return <LoadingCard message="Loading duplicate files..." />;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="text-center p-8 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
|
|
|
+ <h2 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">
|
|
|
|
|
+ Failed to load duplicates
|
|
|
|
|
+ </h2>
|
|
|
|
|
+ <p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
|
|
|
+ There was an error loading duplicate file data.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ queryClient.refetchQueries({ queryKey: ["duplicate-files"] })
|
|
|
|
|
+ }
|
|
|
|
|
+ className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
|
|
|
|
|
+ >
|
|
|
|
|
+ Retry
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-6">
|
|
|
|
|
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
|
|
|
+ Duplicate Files
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <p className="text-sm text-gray-600 dark:text-gray-400">
|
|
|
|
|
+ Review detected duplicates, delete unwanted files, or mark them as
|
|
|
|
|
+ safe.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => scanMutation.mutate()}
|
|
|
|
|
+ className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <ArrowPathIcon className="h-4 w-4" />
|
|
|
|
|
+ Rescan
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={markSelectedNotDuplicate}
|
|
|
|
|
+ disabled={selectedGroups.size === 0}
|
|
|
|
|
+ className="inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-2 text-sm font-medium text-white shadow-sm disabled:opacity-60 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <CheckCircleIcon className="h-4 w-4" />
|
|
|
|
|
+ Mark Not Duplicate
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={handleBatchDelete}
|
|
|
|
|
+ disabled={selectedFiles.size === 0}
|
|
|
|
|
+ className="inline-flex items-center gap-2 rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white shadow-sm disabled:opacity-60 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <TrashIcon className="h-4 w-4" />
|
|
|
|
|
+ Delete Selected Files
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm space-y-4">
|
|
|
|
|
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <Squares2X2Icon className="h-5 w-5 text-gray-500" />
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="text-sm text-gray-700 dark:text-gray-300">
|
|
|
|
|
+ Showing {filteredData.length} group
|
|
|
|
|
+ {filteredData.length === 1 ? "" : "s"}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ {selectedFiles.size} file{selectedFiles.size === 1 ? "" : "s"}{" "}
|
|
|
|
|
+ selected
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-3">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ placeholder="Search destination or file path..."
|
|
|
|
|
+ value={searchTerm}
|
|
|
|
|
+ onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
+ className="w-64 min-w-[200px] rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ <select
|
|
|
|
|
+ className="rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
+ value={sortField}
|
|
|
|
|
+ onChange={(e) => setSortField(e.target.value as SortField)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="count">Most files</option>
|
|
|
|
|
+ <option value="size">Largest size</option>
|
|
|
|
|
+ <option value="dataset">Dataset</option>
|
|
|
|
|
+ <option value="created_at">Newest</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"))
|
|
|
|
|
+ }
|
|
|
|
|
+ className="rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
|
|
|
+ >
|
|
|
|
|
+ {sortDirection === "asc" ? "Asc" : "Desc"}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex flex-wrap gap-2 items-center">
|
|
|
|
|
+ <span className="text-sm text-gray-600 dark:text-gray-400">
|
|
|
|
|
+ Datasets:
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {datasets?.map((ds: string) => {
|
|
|
|
|
+ const name = ds.split("/").pop();
|
|
|
|
|
+ if (!name) return null;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <label
|
|
|
|
|
+ key={name}
|
|
|
|
|
+ className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
|
|
|
|
|
+ >
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={enabledDatasets.has(name)}
|
|
|
|
|
+ onChange={() => toggleDataset(name)}
|
|
|
|
|
+ className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ {name}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
|
|
|
|
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
|
|
|
|
+ <thead className="bg-gray-50 dark:bg-gray-800">
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th className="px-3 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
|
|
|
+ Group
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="px-3 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
|
|
|
+ Dataset
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="px-3 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
|
|
|
+ Destination
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="px-3 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
|
|
|
+ Files
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="px-3 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
|
|
|
+ Size
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="px-3 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
|
|
|
+ Hash
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="px-3 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
|
|
|
+ Actions
|
|
|
|
|
+ </th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
|
|
|
|
+ {filteredData.length === 0 && (
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td
|
|
|
|
|
+ colSpan={7}
|
|
|
|
|
+ className="px-3 py-4 text-center text-sm text-gray-500 dark:text-gray-400"
|
|
|
|
|
+ >
|
|
|
|
|
+ No duplicate groups match the current filters.
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {filteredData.map((group) => {
|
|
|
|
|
+ const isExpanded = expandedRows.has(group.id);
|
|
|
|
|
+ const allSelected = group.files.every((f) =>
|
|
|
|
|
+ selectedFiles.has(makeFileKey(group.id, f))
|
|
|
|
|
+ );
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <tr
|
|
|
|
|
+ key={group.id}
|
|
|
|
|
+ className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
|
|
|
+ >
|
|
|
|
|
+ <td className="px-3 py-3 text-sm text-gray-700 dark:text-gray-200 whitespace-nowrap">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => toggleExpanded(group.id)}
|
|
|
|
|
+ className="mr-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
|
|
|
+ >
|
|
|
|
|
+ {isExpanded ? "−" : "+"}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={selectedGroups.has(group.id)}
|
|
|
|
|
+ onChange={() => toggleGroupSelection(group.id)}
|
|
|
|
|
+ className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
|
|
|
|
+ <span className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded text-xs font-medium">
|
|
|
|
|
+ {group.dataset}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-3 py-3 text-sm text-gray-700 dark:text-gray-300 max-w-xs">
|
|
|
|
|
+ <div className="truncate" title={group.destination}>
|
|
|
|
|
+ {group.destination}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-3 py-3 text-sm text-gray-700 dark:text-gray-300">
|
|
|
|
|
+ {group.files.length}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-3 py-3 text-sm text-gray-700 dark:text-gray-300">
|
|
|
|
|
+ {formatBytes(group.size)}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ {group.hash.slice(0, 10)}…
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-3 py-3 text-sm text-right whitespace-nowrap">
|
|
|
|
|
+ <div className="inline-flex items-center gap-1">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ markNotDuplicateMutation.mutate([group.id])
|
|
|
|
|
+ }
|
|
|
|
|
+ className="inline-flex items-center gap-1 rounded-md border border-gray-300 dark:border-gray-700 px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
|
|
|
+ >
|
|
|
|
|
+ <EyeSlashIcon className="h-4 w-4" /> Ignore
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ selectGroupFiles(group, !allSelected)
|
|
|
|
|
+ }
|
|
|
|
|
+ className="inline-flex items-center gap-1 rounded-md border border-gray-300 dark:border-gray-700 px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
|
|
|
+ >
|
|
|
|
|
+ {allSelected ? "Unselect files" : "Select files"}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ {isExpanded && (
|
|
|
|
|
+ <tr
|
|
|
|
|
+ key={`${group.id}-expanded`}
|
|
|
|
|
+ className="bg-gray-50 dark:bg-gray-800/60"
|
|
|
|
|
+ >
|
|
|
|
|
+ <td colSpan={7} className="px-3 py-3">
|
|
|
|
|
+ <div className="flex items-center justify-between mb-2">
|
|
|
|
|
+ <div className="text-sm font-medium text-gray-800 dark:text-gray-100">
|
|
|
|
|
+ Files in group
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
|
|
|
|
+ <span>{selectedFiles.size} selected</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => selectGroupFiles(group, true)}
|
|
|
|
|
+ className="rounded-md border border-gray-300 dark:border-gray-700 px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
|
|
|
+ >
|
|
|
|
|
+ Select all
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => selectGroupFiles(group, false)}
|
|
|
|
|
+ className="rounded-md border border-gray-300 dark:border-gray-700 px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
|
|
|
+ >
|
|
|
|
|
+ Clear
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
|
|
|
|
+ {group.files.map((file) => {
|
|
|
|
|
+ const key = makeFileKey(group.id, file);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <label
|
|
|
|
|
+ key={key}
|
|
|
|
|
+ className="flex items-center gap-3 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-200"
|
|
|
|
|
+ >
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={selectedFiles.has(key)}
|
|
|
|
|
+ onChange={() =>
|
|
|
|
|
+ toggleFileSelection(group.id, file)
|
|
|
|
|
+ }
|
|
|
|
|
+ className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="truncate" title={file}>
|
|
|
|
|
+ {file}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <ConfirmationDialog
|
|
|
|
|
+ isOpen={deleteSelection.isOpen}
|
|
|
|
|
+ title="Delete selected files"
|
|
|
|
|
+ message="These files will be removed from disk. This action cannot be undone."
|
|
|
|
|
+ confirmText="Delete"
|
|
|
|
|
+ cancelText="Cancel"
|
|
|
|
|
+ type="danger"
|
|
|
|
|
+ isLoading={deleteMutation.isPending}
|
|
|
|
|
+ onConfirm={confirmDelete}
|
|
|
|
|
+ onClose={() => setDeleteSelection({ isOpen: false, groups: [] })}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|