feat: manage duplicates in bulk
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
1 parent
2746fe0938
commit
d443da7df2
11 files changed
+904
-13
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{humanizeNumber(selectedCount)} of {humanizeNumber(totalAssets)} selected
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Check size={16} />
|
||||
Select All
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDeselectAll}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<X size={16} />
|
||||
Deselect All
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onInvertSelection}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Invert
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDeleteSelected}
|
||||
disabled={selectedCount === 0}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete Selected ({selectedCount})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string>
|
||||
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 (
|
||||
<div className="border rounded-lg overflow-hidden shadow-lg relative group">
|
||||
<label className="block relative cursor-pointer">
|
||||
<LazyImage
|
||||
src={ASSET_THUMBNAIL_PATH(asset.id)}
|
||||
alt={asset.originalFileName}
|
||||
title={asset.originalFileName}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Overlay info */}
|
||||
<div className="absolute bottom-0 w-full bg-gray-800/70 text-white text-center text-xs font-bold py-1 group-hover:hidden">
|
||||
{formatDate(asset.exifInfo.dateTimeOriginal?.toString(), 'MMM d, yyyy')}
|
||||
</div>
|
||||
|
||||
{/* Keep/Trash label */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{isSelected ? (
|
||||
<div className="bg-green-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Shield size={12} />
|
||||
KEEP
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-red-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Trash2 size={12} />
|
||||
TRASH
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Checkbox */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={handleCheckboxChange}
|
||||
className="absolute top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
/>
|
||||
|
||||
{/* Asset type indicator */}
|
||||
{asset.type === 'VIDEO' && (
|
||||
<div className="absolute top-12 right-2 bg-gray-800 text-white text-xs py-1 px-2 rounded">
|
||||
VIDEO
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Asset details */}
|
||||
<div className={`p-3 ${isSelected ? 'bg-blue-500' : ''}`}>
|
||||
<h3 className={`text-sm font-semibold truncate ${isSelected ? 'text-white' : ''}`}>
|
||||
{asset.originalFileName}
|
||||
</h3>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className={`flex items-center gap-1 text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
<HardDrive size={12} />
|
||||
{humanizeBytes(asset.exifInfo.fileSizeInByte)}
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-1 text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
<Camera size={12} />
|
||||
{asset.exifInfo.exifImageWidth} × {asset.exifInfo.exifImageHeight}
|
||||
</div>
|
||||
|
||||
{asset.exifInfo.city && (
|
||||
<div className={`flex items-center gap-1 text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
<MapPin size={12} />
|
||||
{asset.exifInfo.city}, {asset.exifInfo.country}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{asset.exifInfo.make && asset.exifInfo.model && (
|
||||
<div className={`text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
{asset.exifInfo.make} {asset.exifInfo.model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Camera size={16} />
|
||||
{humanizeNumber(record.assets.length)} duplicate{record.assets.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive size={16} />
|
||||
{humanizeBytes(totalSize)} total
|
||||
</div>
|
||||
{selectedInRecord > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
|
||||
<Shield size={16} />
|
||||
{selectedInRecord} to keep ({humanizeBytes(selectedSize)})
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-red-600 dark:text-red-400 font-medium">
|
||||
<Trash2 size={16} />
|
||||
{unselectedInRecord} to delete ({humanizeBytes(unselectedSize)} savings)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertDialog
|
||||
title="Keep All Assets"
|
||||
description={`Are you sure you want to keep all ${record.assets.length} assets in this group? This will remove them from the duplicate detection and they won't appear as duplicates anymore.`}
|
||||
onConfirm={handleKeepAll}
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
title="Remove from duplicate detection"
|
||||
>
|
||||
<Shield size={16} />
|
||||
Keep All
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
|
||||
{selectedInRecord > 0 && (
|
||||
<AlertDialog
|
||||
title="Keep Selected Assets"
|
||||
description={`Keep ${selectedInRecord} selected assets and delete ${unselectedInRecord} unselected assets? The selected assets will be removed from duplicate detection. Storage savings: ${humanizeBytes(unselectedSize)}. This action cannot be undone.`}
|
||||
onConfirm={handleKeepSelected}
|
||||
variant="destructive"
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
title={`Keep selected, delete others (${humanizeBytes(unselectedSize)} savings)`}
|
||||
>
|
||||
<Shield size={16} />
|
||||
Keep Selected ({selectedInRecord})
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
title="Delete All Assets"
|
||||
description={`Are you sure you want to delete all ${record.assets.length} assets in this duplicate group? Storage savings: ${humanizeBytes(totalSize)}. This action cannot be undone.`}
|
||||
onConfirm={handleDeleteRecord}
|
||||
variant="destructive"
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
title={`Delete group (${humanizeBytes(totalSize)} savings)`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete All ({humanizeBytes(totalSize)})
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{record.assets.map((asset) => (
|
||||
<DuplicateAssetItem
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedAssets.has(asset.id)}
|
||||
onSelect={onAssetSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string>
|
||||
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<string>
|
||||
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<ListItemProps> = ({ index, style, data }) => {
|
||||
const { duplicates, selectedAssets, onAssetSelect, onDeleteRecord, onKeepSelected, onKeepAllInRecord } = data
|
||||
const record = duplicates[index]
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<DuplicateAssetRecord
|
||||
record={record}
|
||||
selectedAssets={selectedAssets}
|
||||
onAssetSelect={onAssetSelect}
|
||||
onDeleteRecord={onDeleteRecord}
|
||||
onKeepSelected={onKeepSelected}
|
||||
onKeepAllInRecord={onKeepAllInRecord}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ height }}>
|
||||
{duplicates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No duplicate records to display</p>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
height={height}
|
||||
width="100%"
|
||||
itemCount={duplicates.length}
|
||||
itemSize={getItemHeight}
|
||||
itemData={itemData}
|
||||
overscanCount={2}
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#cbd5e1 transparent'
|
||||
}}
|
||||
>
|
||||
{ListItem}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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: <Video className="h-4 w-4" />,
|
||||
}
|
||||
},
|
||||
// Bulk Duplicate
|
||||
{
|
||||
title: "Bulk Duplicate Finder",
|
||||
link: "/assets/bulk-duplicate-finder",
|
||||
icon: <Copy className="h-4 w-4" />,
|
||||
},
|
||||
|
||||
];
|
||||
@@ -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";
|
||||
export const DOWNLOAD_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/download/archive";
|
||||
|
||||
export const LIST_DUPLICATES_PATH = BASE_PROXY_ENDPOINT + "/duplicates";
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
export const listDuplicates = async () => {
|
||||
return API.get(LIST_DUPLICATES_PATH);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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<IDuplicateAssetRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAssets, setSelectedAssets] = useState<Set<string>>(new Set());
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number>(-1);
|
||||
const [containerHeight, setContainerHeight] = useState<number>(600);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<PageLayout>
|
||||
<Header
|
||||
leftComponent="Bulk Duplicate Finder"
|
||||
rightComponent={
|
||||
<Button
|
||||
onClick={fetchDuplicates}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-full overflow-hidden">
|
||||
{/* Description - only show when loading or no duplicates */}
|
||||
{(loading || error || duplicates.length === 0) && (
|
||||
<div className="p-6 pb-0">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Manage and remove duplicate assets from your library
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 px-6">
|
||||
<Loader />
|
||||
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||
Scanning for duplicate assets...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-6">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-600 dark:text-red-400">Error:</span>
|
||||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchDuplicates}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && duplicates.length === 0 && (
|
||||
<div className="text-center py-12 px-6">
|
||||
<Search size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No duplicates found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Great! No duplicate assets were detected in your library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && duplicates.length > 0 && (
|
||||
<div ref={containerRef} style={{ height: containerHeight }} className="overflow-hidden">
|
||||
<VirtualizedDuplicateList
|
||||
duplicates={duplicates}
|
||||
selectedAssets={selectedAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
onDeleteRecord={handleDeleteRecord}
|
||||
onKeepSelected={handleKeepSelected}
|
||||
onKeepAllInRecord={handleKeepAllInRecord}
|
||||
height={containerHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{isDeleting && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
Deleting Assets
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Removing duplicate assets...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
Vendored
+61
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user