diff --git a/bun.lockb b/bun.lockb
index c4de24e..158441f 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 4aacb22..cc00a8b 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"@types/qs": "^6.9.15",
"@types/react-autosuggest": "^10.1.11",
"@types/react-calendar-heatmap": "^1.6.7",
+ "@types/react-window": "^1.8.8",
"@vis.gl/react-google-maps": "^1.4.2",
"axios": "^1.7.4",
"chrono-node": "^1.4.9",
@@ -66,6 +67,7 @@
"react-hot-toast": "^2.5.1",
"react-leaflet": "^4.2.1",
"react-mentions": "^4.4.10",
+ "react-window": "^1.8.11",
"recharts": "^2.12.7",
"remotion": "4.0.232",
"sass": "^1.77.8",
diff --git a/src/components/assets/duplicate-assets/BulkActionBar.tsx b/src/components/assets/duplicate-assets/BulkActionBar.tsx
new file mode 100644
index 0000000..4b51463
--- /dev/null
+++ b/src/components/assets/duplicate-assets/BulkActionBar.tsx
@@ -0,0 +1,81 @@
+import React from 'react'
+import { Button } from '@/components/ui/button'
+import { Trash2, Check, X, RotateCcw } from 'lucide-react'
+import { humanizeNumber } from '@/helpers/string.helper'
+
+interface BulkActionBarProps {
+ selectedCount: number
+ totalAssets: number
+ onDeleteSelected: () => void
+ onSelectAll: () => void
+ onDeselectAll: () => void
+ onInvertSelection: () => void
+ isVisible: boolean
+}
+
+export default function BulkActionBar({
+ selectedCount,
+ totalAssets,
+ onDeleteSelected,
+ onSelectAll,
+ onDeselectAll,
+ onInvertSelection,
+ isVisible
+}: BulkActionBarProps) {
+ if (!isVisible) return null
+
+ return (
+
+
+
+ {humanizeNumber(selectedCount)} of {humanizeNumber(totalAssets)} selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx b/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx
new file mode 100644
index 0000000..1248450
--- /dev/null
+++ b/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx
@@ -0,0 +1,265 @@
+import React, { useMemo } from 'react'
+import { IDuplicateAssetRecord, IDuplicateAsset } from '@/types/asset'
+import { ASSET_THUMBNAIL_PATH } from '@/config/routes'
+import LazyImage from '@/components/ui/lazy-image'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Button } from '@/components/ui/button'
+import { AlertDialog } from '@/components/ui/alert-dialog'
+import { humanizeBytes, humanizeNumber } from '@/helpers/string.helper'
+import { formatDate } from '@/helpers/date.helper'
+import { Camera, Calendar, HardDrive, MapPin, Trash2, Check, X, Shield } from 'lucide-react'
+
+interface DuplicateAssetRecordProps {
+ record: IDuplicateAssetRecord
+ selectedAssets: Set
+ onAssetSelect: (assetId: string, isShiftClick?: boolean) => void
+ onDeleteRecord: (record: IDuplicateAssetRecord) => void
+ onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void
+ onKeepAllInRecord: (record: IDuplicateAssetRecord) => void
+}
+
+interface DuplicateAssetItemProps {
+ asset: IDuplicateAsset
+ isSelected: boolean
+ onSelect: (assetId: string, isShiftClick?: boolean) => void
+}
+
+function DuplicateAssetItem({ asset, isSelected, onSelect }: DuplicateAssetItemProps) {
+ const handleCheckboxChange = (event: React.MouseEvent) => {
+ const isShiftClick = event.shiftKey
+ onSelect(asset.id, isShiftClick)
+ }
+
+ return (
+
+
+
+ {/* Asset details */}
+
+
+ {asset.originalFileName}
+
+
+
+
+
+ {humanizeBytes(asset.exifInfo.fileSizeInByte)}
+
+
+
+
+ {asset.exifInfo.exifImageWidth} × {asset.exifInfo.exifImageHeight}
+
+
+ {asset.exifInfo.city && (
+
+
+ {asset.exifInfo.city}, {asset.exifInfo.country}
+
+ )}
+
+ {asset.exifInfo.make && asset.exifInfo.model && (
+
+ {asset.exifInfo.make} {asset.exifInfo.model}
+
+ )}
+
+
+
+ )
+}
+
+export default function DuplicateAssetRecord({
+ record,
+ selectedAssets,
+ onAssetSelect,
+ onDeleteRecord,
+ onKeepSelected,
+ onKeepAllInRecord
+}: DuplicateAssetRecordProps) {
+ const recordAssetIds = record.assets.map(asset => asset.id)
+ const selectedInRecord = recordAssetIds.filter(id => selectedAssets.has(id)).length
+ const unselectedInRecord = record.assets.length - selectedInRecord
+
+ // Calculate total size and selected/unselected sizes
+ const totalSize = useMemo(() => {
+ return record.assets.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0)
+ }, [record.assets])
+
+ const selectedSize = useMemo(() => {
+ return record.assets
+ .filter(asset => selectedAssets.has(asset.id))
+ .reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0)
+ }, [record.assets, selectedAssets])
+
+ const unselectedSize = useMemo(() => {
+ return record.assets
+ .filter(asset => !selectedAssets.has(asset.id))
+ .reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0)
+ }, [record.assets, selectedAssets])
+
+ const handleKeepAll = () => {
+ onKeepAllInRecord(record)
+ }
+
+ const handleDeleteRecord = () => {
+ onDeleteRecord(record)
+ }
+
+ const handleKeepSelected = () => {
+ const selectedAssetIds = record.assets
+ .filter(asset => selectedAssets.has(asset.id))
+ .map(asset => asset.id)
+
+ const unselectedAssetIds = record.assets
+ .filter(asset => !selectedAssets.has(asset.id))
+ .map(asset => asset.id)
+
+ if (selectedAssetIds.length === 0) return
+
+ onKeepSelected(record, selectedAssetIds, unselectedAssetIds)
+ }
+
+ return (
+
+
+
+
+
+
+ {humanizeNumber(record.assets.length)} duplicate{record.assets.length !== 1 ? 's' : ''}
+
+
+
+ {humanizeBytes(totalSize)} total
+
+ {selectedInRecord > 0 && (
+
+
+
+ {selectedInRecord} to keep ({humanizeBytes(selectedSize)})
+
+
+
+ {unselectedInRecord} to delete ({humanizeBytes(unselectedSize)} savings)
+
+
+ )}
+
+
+
+
+
+
+ {selectedInRecord > 0 && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {record.assets.map((asset) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx
new file mode 100644
index 0000000..8e580c3
--- /dev/null
+++ b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx
@@ -0,0 +1,115 @@
+import React, { useMemo } from 'react'
+import { VariableSizeList as List } from 'react-window'
+import { IDuplicateAssetRecord } from '@/types/asset'
+import DuplicateAssetRecord from './DuplicateAssetRecord'
+
+interface VirtualizedDuplicateListProps {
+ duplicates: IDuplicateAssetRecord[]
+ selectedAssets: Set
+ onAssetSelect: (assetId: string, isShiftClick?: boolean) => void
+ onDeleteRecord: (record: IDuplicateAssetRecord) => void
+ onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void
+ onKeepAllInRecord: (record: IDuplicateAssetRecord) => void
+ height: number
+}
+
+interface ListItemProps {
+ index: number
+ style: React.CSSProperties
+ data: {
+ duplicates: IDuplicateAssetRecord[]
+ selectedAssets: Set
+ onAssetSelect: (assetId: string, isShiftClick?: boolean) => void
+ onDeleteRecord: (record: IDuplicateAssetRecord) => void
+ onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void
+ onKeepAllInRecord: (record: IDuplicateAssetRecord) => void
+ }
+}
+
+const ListItem: React.FC = ({ index, style, data }) => {
+ const { duplicates, selectedAssets, onAssetSelect, onDeleteRecord, onKeepSelected, onKeepAllInRecord } = data
+ const record = duplicates[index]
+
+ return (
+
+ )
+}
+
+// Calculate dynamic height for each duplicate record
+const getItemSize = (record: IDuplicateAssetRecord): number => {
+ // Base height for the header section
+ let height = 120 // Header + margin
+
+ // Calculate grid rows needed
+ const assetsPerRow = 5 // xl:grid-cols-5 is the max
+ const rows = Math.ceil(record.assets.length / assetsPerRow)
+
+ // Each asset item is approximately 320px tall (200px image + 120px details)
+ const assetRowHeight = 320
+
+ return height + (rows * assetRowHeight) + 32 // Add some margin
+}
+
+export default function VirtualizedDuplicateList({
+ duplicates,
+ selectedAssets,
+ onAssetSelect,
+ onDeleteRecord,
+ onKeepSelected,
+ onKeepAllInRecord,
+ height
+}: VirtualizedDuplicateListProps) {
+ // Custom item size getter for VariableSizeList
+ const getItemHeight = (index: number) => {
+ if (index >= duplicates.length) return 0
+ return getItemSize(duplicates[index])
+ }
+
+ // Data to pass to each list item
+ const itemData = {
+ duplicates,
+ selectedAssets,
+ onAssetSelect,
+ onDeleteRecord,
+ onKeepSelected,
+ onKeepAllInRecord
+ }
+
+ // For better performance with many items, we'll use a custom implementation
+ // that handles variable heights properly
+ return (
+
+ {duplicates.length === 0 ? (
+
+
No duplicate records to display
+
+ ) : (
+
+ {ListItem}
+
+ )}
+
+ )
+}
diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx
index b4de6fc..6b95734 100644
--- a/src/config/constants/sidebarNavs.tsx
+++ b/src/config/constants/sidebarNavs.tsx
@@ -1,4 +1,4 @@
-import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react";
+import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react";
export const sidebarNavs = [
// {
@@ -47,6 +47,12 @@ export const sidebarNavs = [
title: "Empty Videos",
link: "/assets/empty-videos",
icon: ,
- }
+ },
+ // Bulk Duplicate
+ {
+ title: "Bulk Duplicate Finder",
+ link: "/assets/bulk-duplicate-finder",
+ icon: ,
+ },
];
diff --git a/src/config/routes.ts b/src/config/routes.ts
index 3a3aec3..bffdcb0 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -73,4 +73,6 @@ export const SHARE_LINK_GENERATE_PATH = BASE_API_ENDPOINT + "/share-link/generat
export const SHARE_LINK_ASSETS_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/assets";
export const SHARE_LINK_PEOPLE_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/people";
export const SHARE_LINK_DOWNLOAD_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/download";
-export const DOWNLOAD_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/download/archive";
\ No newline at end of file
+export const DOWNLOAD_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/download/archive";
+
+export const LIST_DUPLICATES_PATH = BASE_PROXY_ENDPOINT + "/duplicates";
\ No newline at end of file
diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts
index ab0f686..7b86638 100644
--- a/src/handlers/api/asset.handler.ts
+++ b/src/handlers/api/asset.handler.ts
@@ -6,6 +6,7 @@ import {
LIST_MISSING_LOCATION_ASSETS_PATH,
LIST_MISSING_LOCATION_DATES_PATH,
UPDATE_ASSETS_PATH,
+ LIST_DUPLICATES_PATH,
} from "@/config/routes";
import { cleanUpAsset } from "@/helpers/asset.helper";
import API from "@/lib/api";
@@ -51,6 +52,7 @@ export interface IUpdateAssetsParams {
latitude?: number;
longitude?: number;
dateTimeOriginal?: string;
+ duplicateId?: string | null;
}
export const updateAssets = async (params: IUpdateAssetsParams) => {
@@ -84,4 +86,8 @@ export interface IEmptyVideosParams {
export const listEmptyVideos = async (filters: IEmptyVideosParams) => {
return API.get(LIST_EMPTY_VIDEOS_PATH, filters).then((assets) => assets.map(cleanUpAsset));
-}
\ No newline at end of file
+}
+
+export const listDuplicates = async () => {
+ return API.get(LIST_DUPLICATES_PATH);
+}
\ No newline at end of file
diff --git a/src/pages/api/immich-proxy/[...path].ts b/src/pages/api/immich-proxy/[...path].ts
index 3beaf92..cb195ea 100644
--- a/src/pages/api/immich-proxy/[...path].ts
+++ b/src/pages/api/immich-proxy/[...path].ts
@@ -22,24 +22,49 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Forward the status code
res.status(response.status)
- // Forward the headers
+ // Forward the headers, but filter out problematic ones
const responseHeaders = response.headers
responseHeaders.forEach((value, key) => {
- res.setHeader(key, value)
+ // Skip headers that can cause decoding issues
+ const skipHeaders = [
+ 'content-encoding',
+ 'content-length',
+ 'transfer-encoding',
+ 'connection',
+ 'keep-alive'
+ ]
+
+ if (!skipHeaders.includes(key.toLowerCase())) {
+ res.setHeader(key, value)
+ }
})
- // Only stream the response body if it exists and has content
+ // Handle the response body properly
if (response.body) {
- const reader = response.body.getReader()
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- res.write(value)
+ // For binary content or when we need to preserve encoding
+ if (response.headers.get('content-type')?.includes('image/') ||
+ response.headers.get('content-type')?.includes('video/') ||
+ response.headers.get('content-type')?.includes('audio/') ||
+ response.headers.get('content-type')?.includes('application/octet-stream')) {
+
+ const arrayBuffer = await response.arrayBuffer()
+ res.send(Buffer.from(arrayBuffer))
+ } else {
+ // For text/JSON content, let Next.js handle the encoding
+ const text = await response.text()
+ res.send(text)
}
+ } else {
+ res.end()
}
- res.end()
} catch (error: any) {
console.error('Proxy error:', error)
res.status(500).json({ error: error?.message })
}
+}
+
+export const config = {
+ api: {
+ responseLimit: false,
+ },
}
\ No newline at end of file
diff --git a/src/pages/assets/bulk-duplicate-finder.tsx b/src/pages/assets/bulk-duplicate-finder.tsx
new file mode 100644
index 0000000..3623ee6
--- /dev/null
+++ b/src/pages/assets/bulk-duplicate-finder.tsx
@@ -0,0 +1,328 @@
+import { listDuplicates, deleteAssets, updateAssets } from "@/handlers/api/asset.handler";
+import { IDuplicateAssetRecord } from "@/types/asset";
+import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
+import PageLayout from '@/components/layouts/PageLayout'
+import Header from '@/components/shared/Header'
+import VirtualizedDuplicateList from '@/components/assets/duplicate-assets/VirtualizedDuplicateList'
+import Loader from '@/components/ui/loader'
+import { Button } from '@/components/ui/button'
+import { RefreshCw, Search } from 'lucide-react'
+
+import { toast } from '@/components/ui/use-toast'
+import { humanizeBytes } from '@/helpers/string.helper'
+
+export default function BulkDuplicatePage() {
+ const [duplicates, setDuplicates] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedAssets, setSelectedAssets] = useState>(new Set());
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
+ const [containerHeight, setContainerHeight] = useState(600);
+ const containerRef = useRef(null);
+
+ const fetchDuplicates = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const duplicates = await listDuplicates();
+ setDuplicates(duplicates);
+ setSelectedAssets(new Set()); // Clear selection when refetching
+ } catch (error: any) {
+ setError(error.message || 'Failed to load duplicate assets');
+ toast({
+ title: "Error",
+ description: "Failed to load duplicate assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchDuplicates();
+ }, []);
+
+ // Handle container height calculation and resize
+ useEffect(() => {
+ const updateHeight = () => {
+ // Calculate available height: full viewport minus header (48px)
+ const viewportHeight = window.innerHeight;
+ const headerHeight = 48; // h-12 = 48px
+ const availableHeight = viewportHeight - headerHeight;
+ setContainerHeight(availableHeight);
+ };
+
+ // Initial calculation
+ updateHeight();
+
+ // Update on window resize
+ window.addEventListener('resize', updateHeight);
+
+ // Cleanup
+ return () => {
+ window.removeEventListener('resize', updateHeight);
+ };
+ }, [duplicates]); // Re-calculate when duplicates change
+
+ // Flatten all asset IDs for selection operations
+ const allAssetIds = useMemo(() => {
+ return duplicates.flatMap(record => record.assets.map(asset => asset.id));
+ }, [duplicates]);
+
+ const handleAssetSelect = useCallback((assetId: string, isShiftClick?: boolean) => {
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+
+ if (isShiftClick && lastSelectedIndex >= 0) {
+ // Find the indices of the current and last selected assets
+ const currentIndex = allAssetIds.indexOf(assetId);
+ const start = Math.min(lastSelectedIndex, currentIndex);
+ const end = Math.max(lastSelectedIndex, currentIndex);
+
+ // Select all assets between the two indices
+ for (let i = start; i <= end; i++) {
+ newSelection.add(allAssetIds[i]);
+ }
+ setLastSelectedIndex(currentIndex);
+ } else {
+ // Regular selection toggle
+ if (newSelection.has(assetId)) {
+ newSelection.delete(assetId);
+ } else {
+ newSelection.add(assetId);
+ }
+ setLastSelectedIndex(allAssetIds.indexOf(assetId));
+ }
+
+ return newSelection;
+ });
+ }, [allAssetIds, lastSelectedIndex]);
+
+
+
+ const handleDeleteRecord = async (record: IDuplicateAssetRecord) => {
+ setIsDeleting(true);
+ try {
+ const assetIds = record.assets.map(asset => asset.id);
+ const totalSize = record.assets.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0);
+ await deleteAssets(assetIds);
+
+ // Remove the entire record from duplicates
+ setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId));
+
+ // Remove any selected assets from this record
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+ assetIds.forEach(id => newSelection.delete(id));
+ return newSelection;
+ });
+
+ setLastSelectedIndex(-1);
+
+ toast({
+ title: "Success",
+ description: `Successfully deleted ${record.assets.length} duplicate assets and saved ${humanizeBytes(totalSize)} of storage.`,
+ });
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to delete assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleKeepAllInRecord = useCallback(async (record: IDuplicateAssetRecord) => {
+ setIsDeleting(true);
+ try {
+ const assetIds = record.assets.map(asset => asset.id);
+
+ // Update assets to remove duplicateId (set to null)
+ await updateAssets({
+ ids: assetIds,
+ duplicateId: null
+ });
+
+ // Remove the entire record from duplicates
+ setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId));
+
+ // Remove any selected assets from this record
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+ assetIds.forEach(id => newSelection.delete(id));
+ return newSelection;
+ });
+
+ setLastSelectedIndex(-1);
+
+ toast({
+ title: "Success",
+ description: `Successfully kept ${record.assets.length} assets. They will no longer appear as duplicates.`,
+ });
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to keep assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ }, [duplicates]);
+
+ const handleKeepSelected = async (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => {
+ setIsDeleting(true);
+ try {
+ const unselectedSize = record.assets
+ .filter(asset => unselectedIds.includes(asset.id))
+ .reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0);
+
+ // First, update selected assets to remove duplicateId (mark as kept)
+ if (selectedIds.length > 0) {
+ await updateAssets({
+ ids: selectedIds,
+ duplicateId: null
+ });
+ }
+
+ // Then, delete unselected assets
+ if (unselectedIds.length > 0) {
+ await deleteAssets(unselectedIds);
+ }
+
+ // Remove the entire record from duplicates (since selected assets are no longer duplicates)
+ setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId));
+
+ // Remove all assets from this record from selection
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+ [...selectedIds, ...unselectedIds].forEach(id => newSelection.delete(id));
+ return newSelection;
+ });
+
+ setLastSelectedIndex(-1);
+
+ toast({
+ title: "Success",
+ description: `Successfully kept ${selectedIds.length} assets and deleted ${unselectedIds.length} duplicates. Storage saved: ${humanizeBytes(unselectedSize)}.`,
+ });
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to process selected assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+
+
+ return (
+
+
+
+ Refresh
+
+ }
+ />
+
+
+ {/* Description - only show when loading or no duplicates */}
+ {(loading || error || duplicates.length === 0) && (
+
+
+ Manage and remove duplicate assets from your library
+
+
+ )}
+
+ {loading && (
+
+
+
+ Scanning for duplicate assets...
+
+
+ )}
+
+ {error && (
+
+
+
+ Error:
+ {error}
+
+
+
+
+ )}
+
+ {!loading && !error && duplicates.length === 0 && (
+
+
+
+ No duplicates found
+
+
+ Great! No duplicate assets were detected in your library.
+
+
+ )}
+
+ {!loading && !error && duplicates.length > 0 && (
+
+
+
+ )}
+
+
+
+
+ {isDeleting && (
+
+
+
+
+
+
+ Deleting Assets
+
+
+ Removing duplicate assets...
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts
index 71af4aa..71f4fdb 100644
--- a/src/types/asset.d.ts
+++ b/src/types/asset.d.ts
@@ -24,3 +24,64 @@ export interface IAssetThumbhash {
type: string;
data: number[];
}
+
+
+export interface IDuplicateAssetRecord {
+ duplicateId: string;
+ assets: IDuplicateAsset[];
+}
+
+export interface IDuplicateAsset {
+ id: string;
+ deviceAssetId: string;
+ ownerId: string;
+ deviceId: string;
+ libraryId: null;
+ type: string;
+ originalPath: string;
+ originalFileName: string;
+ originalMimeType: string;
+ thumbhash: string;
+ fileCreatedAt: Date;
+ fileModifiedAt: Date;
+ localDateTime: Date;
+ updatedAt: Date;
+ isFavorite: boolean;
+ isArchived: boolean;
+ isTrashed: boolean;
+ visibility: string;
+ duration: string;
+ exifInfo: IDuplicateAssetExifInfo;
+ livePhotoVideoId: null;
+ people: any[];
+ checksum: string;
+ isOffline: boolean;
+ hasMetadata: boolean;
+ duplicateId: string;
+ resized: boolean;
+}
+
+export interface IDuplicateAssetExifInfo {
+ make: string;
+ model: string;
+ exifImageWidth: number;
+ exifImageHeight: number;
+ fileSizeInByte: number;
+ orientation: string;
+ dateTimeOriginal: Date;
+ modifyDate: Date;
+ timeZone: string;
+ lensModel: string;
+ fNumber: number;
+ focalLength: number;
+ iso: number;
+ exposureTime: string;
+ latitude: number;
+ longitude: number;
+ city: string;
+ state: string;
+ country: string;
+ description: string;
+ projectionType: null;
+ rating: null;
+}