diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b35c7ea..8972624 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,4 +28,12 @@ You can find the API route here `pages/api/immich-proxy/[...path].ts` Since the `thumbnailPath` of Immich API doesnt give us a direct link to the image, I've created a proxy api that fetches the image and sends it back as a response. **For Person's Thumbnail** -You can find the API route here `pages/api/immich-proxy/thumbnail/[id].ts` \ No newline at end of file +You can find the API route here `pages/api/immich-proxy/thumbnail/[id].ts` + +### Branching Strategy + +- Always create a new branch for your changes +- Always rebase your branch before creating a PR +- Always squash your commits before merging +- Always merge your branch into the `prerelease` branch +- Always delete your branch after merging diff --git a/README.md b/README.md index 92ceefe..3fd8e8a 100644 --- a/README.md +++ b/README.md @@ -48,19 +48,26 @@ services: - .env ``` -Add the Immich API Key and immich url's to the env file (which you already have for the immich) +Add the Immich API Key and immich url's to the env file (which you already have for the immich). +You also need to add `DB_PORT` (Default 5432) and `DB_HOST`, which has to be set to the name of the immich database +container (Default `immich_postgres`). ```bash IMMICH_API_KEY= # your_immich_api_key IMMICH_URL = "http://local-ip-of-immich:port" # Your immich instace ip address and port EXTERNAL_IMMICH_URL = "https://external-address" # External address of immich +DB_PORT = "5432" +DB_HOST = "immich_postgres" + # Optional GOOGLE_MAPS_API_KEY= # Google Maps API Key GEMINI_API_KEY= # Gemini API Key ``` Refer here for obtaining Immich API Key: https://immich.app/docs/features/command-line-interface#obtain-the-api-key +> [!NOTE] When creating the API key, make sure you select all the permissions for the API key. + #### Method 2 - Portainer If you're using portainer, run the docker using `docker run` and add the power tools to the same network as immich. @@ -149,4 +156,4 @@ Google Gemini 1.5 Flash model is used for parsing your search query in "Find" pa ## Contributing -Feel free to contribute to this project, I'm open to any suggestions and improvements. Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. \ No newline at end of file +Feel free to contribute to this project, I'm open to any suggestions and improvements. Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. diff --git a/bun.lockb b/bun.lockb index c4de24e..f828862 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/VIRTUALIZED_ASSET_GRID.md b/docs/VIRTUALIZED_ASSET_GRID.md new file mode 100644 index 0000000..2235259 --- /dev/null +++ b/docs/VIRTUALIZED_ASSET_GRID.md @@ -0,0 +1,155 @@ +# Virtualized Asset Grid Components + +This document explains how to use the new React Window-based virtualized asset grid components for better performance with large datasets. + +## Overview + +The virtualized asset grid components provide significant performance improvements when displaying large numbers of assets by only rendering the items that are currently visible in the viewport. This is especially useful for photo galleries with thousands of images. + +## Components + +### 1. VirtualizedAssetGrid + +A basic virtualized grid with fixed dimensions. + +```tsx +import VirtualizedAssetGrid from '@/components/shared/VirtualizedAssetGrid'; + + +``` + +**Props:** +- `assets`: Array of IAsset objects +- `selectable`: Whether assets can be selected (default: false) +- `onSelectionChange`: Callback when selection changes +- `columnCount`: Number of columns (default: 5) +- `itemSize`: Size of each item in pixels (default: 200) +- `height`: Height of the grid container (default: 600) + +### 2. ResponsiveVirtualizedAssetGrid + +A responsive virtualized grid that automatically adjusts to container size. + +```tsx +import ResponsiveVirtualizedAssetGrid from '@/components/shared/ResponsiveVirtualizedAssetGrid'; + + +``` + +**Props:** +- `assets`: Array of IAsset objects +- `selectable`: Whether assets can be selected (default: false) +- `onSelectionChange`: Callback when selection changes +- `targetItemSize`: Target size for each item (default: 200) +- `gap`: Gap between items in pixels (default: 8) +- `minColumns`: Minimum number of columns (default: 2) +- `maxColumns`: Maximum number of columns (default: 8) +- `className`: Additional CSS classes + +## Features + +### Selection +Both components support asset selection with the following features: +- Click to select/deselect individual assets +- Shift+click for range selection +- ESC key to clear all selections +- Visual indicators for selected items + +### Lightbox Integration +Both components integrate with the existing lightbox for full-screen viewing: +- Click on an asset to open in lightbox +- Video support with duration display +- Download functionality + +### Performance Optimizations +- Only renders visible items +- Lazy loading of images +- Efficient memory usage +- Smooth scrolling performance + +## Migration from AssetGrid + +To migrate from the existing `AssetGrid` component: + +### Before: +```tsx +import AssetGrid from '@/components/shared/AssetGrid'; + + +``` + +### After: +```tsx +import ResponsiveVirtualizedAssetGrid from '@/components/shared/ResponsiveVirtualizedAssetGrid'; + +
+ +
+``` + +**Important:** The virtualized components require a container with a defined height. Make sure to wrap them in a div with appropriate height styling. + +## Custom Hook: useGridDimensions + +The `useGridDimensions` hook can be used to calculate optimal grid dimensions: + +```tsx +import { useGridDimensions } from '@/hooks'; + +const gridDimensions = useGridDimensions({ + containerWidth: 800, + containerHeight: 600, + itemSize: 200, + gap: 8, + minColumns: 2, + maxColumns: 8 +}); +``` + +## Example Usage + +See `src/components/shared/AssetGridExample.tsx` for a complete example showing different configurations of the virtualized grid components. + +## Performance Considerations + +- Use `ResponsiveVirtualizedAssetGrid` for most use cases as it automatically adapts to container size +- Set appropriate `targetItemSize` based on your design requirements +- Consider using smaller `gap` values for dense layouts +- The components work best with containers that have a defined height + +## Browser Support + +The components use modern browser APIs: +- ResizeObserver (for responsive behavior) +- IntersectionObserver (for lazy loading) +- CSS Grid and Flexbox + +For older browsers, consider using polyfills or the basic `VirtualizedAssetGrid` with fixed dimensions. diff --git a/package.json b/package.json index 4aacb22..91dd1ea 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.1", @@ -35,11 +36,12 @@ "@remotion/cli": "4.0.232", "@remotion/eslint-plugin": "^4.0.232", "@tanstack/react-query": "^5.69.0", - "@tanstack/react-table": "^8.20.1", + "@tanstack/react-table": "^8.21.3", "@types/cookie": "^0.6.0", "@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 +68,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/albums/AlbumTableColumns.tsx b/src/components/albums/AlbumTableColumns.tsx new file mode 100644 index 0000000..2109247 --- /dev/null +++ b/src/components/albums/AlbumTableColumns.tsx @@ -0,0 +1,236 @@ +import { ColumnDef } from "@tanstack/react-table" +import { IAlbum } from "@/types/album" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { ArrowUpDown, MoreHorizontal, Calendar, Camera, Users, HardDrive } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { formatDate } from "@/helpers/date.helper" +import { humanizeBytes, humanizeNumber, pluralize } from "@/helpers/string.helper" +import { differenceInDays } from "date-fns" +import Link from "next/link" +import { ASSET_THUMBNAIL_PATH } from "@/config/routes" +import LazyImage from "@/components/ui/lazy-image" + +export const albumColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "albumThumbnailAssetId", + header: "Thumbnail", + cell: ({ row }) => { + const album = row.original + return ( +
+ +
+ ) + }, + enableSorting: false, + }, + { + accessorKey: "albumName", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const album = row.original + return ( + { + // Ensure the link click is not blocked by table events + e.stopPropagation() + }} + > + {album.albumName} + + ) + }, + }, + { + accessorKey: "assetCount", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const count = row.getValue("assetCount") as number + return
{humanizeNumber(count)}
+ }, + }, + { + accessorKey: "faceCount", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const count = row.getValue("faceCount") as number + return
{humanizeNumber(count)} {pluralize(count, 'person', 'people')}
+ }, + }, + { + accessorKey: "size", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const size = row.getValue("size") as string + return
{humanizeBytes(parseInt(size))}
+ }, + }, + { + accessorKey: "firstPhotoDate", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const album = row.original + const numberOfDays = differenceInDays(album.lastPhotoDate, album.firstPhotoDate) + + return ( +
+
+ {album.firstPhotoDate ? formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy') : ''} +
+
+ {numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString()} days`} +
+
+ ) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const date = row.getValue("createdAt") as string + return
{formatDate(date, 'MMM d, yyyy')}
+ }, + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const album = row.original + + return ( + + + + + + Actions + + + View Album + + + + + Share Album + + + Edit Album + + + + Delete Album + + + + ) + }, + }, +] \ No newline at end of file diff --git a/src/components/albums/info/AlbumImages.tsx b/src/components/albums/info/AlbumImages.tsx index 0736e9f..50918ce 100644 --- a/src/components/albums/info/AlbumImages.tsx +++ b/src/components/albums/info/AlbumImages.tsx @@ -1,19 +1,15 @@ -import "yet-another-react-lightbox/styles.css"; import { listAlbumAssets } from "@/handlers/api/album.handler"; import { useConfig } from "@/contexts/ConfigContext"; import { IAlbum, IAlbumPerson } from "@/types/album"; import { IAsset } from "@/types/asset"; -import React, { useEffect, useMemo, useRef, useState, MouseEvent } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Hourglass } from "lucide-react"; import { useRouter } from "next/router"; import { Button } from "@/components/ui/button"; -import { Gallery } from "react-grid-gallery"; -import Lightbox from "yet-another-react-lightbox"; -import Captions from "yet-another-react-lightbox/plugins/captions"; -import LazyGridImage from "@/components/ui/lazy-grid-image"; import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; import { PERSON_THUBNAIL_PATH } from "@/config/routes"; import Image from "next/image"; +import AssetGrid from "@/components/shared/AssetGrid"; interface AlbumImagesProps { album: IAlbum; @@ -40,8 +36,6 @@ export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps) page: 1, }); const [hasMore, setHasMore] = useState(true); - const [index, setIndex] = useState(-1); - const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); const fetchAssets = async () => { setLoading(true); @@ -58,74 +52,24 @@ export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps) .finally(() => setLoading(false)); }; - const images = useMemo(() => { - return assets.map((p) => ({ - ...p, - src: p.url as string, - original: p.previewUrl as string, - width: p.exifImageWidth as number, - height: p.exifImageHeight as number, - isSelected: selectedIds.includes(p.id), - orientation: 1, + const processedAssets = useMemo(() => { + return assets.map((asset) => ({ + ...asset, tags: [ { title: "Immich Link", value: ( - + Open in Immich ), }, ], })); - }, [assets, selectedIds, exImmichUrl]); + }, [assets, exImmichUrl]); - const slides = useMemo( - () => - images.map(({ original, width, height }) => ({ - src: original, - width, - height, - })), - [images] - ); - - const handleClick = (idx: number, asset: IAsset, event: MouseEvent) => { - if (selectedIds.length > 0) { - handleSelect(idx, asset, event); - } else { - setIndex(idx); - } - }; - - const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent) => { - event.stopPropagation(); - const isPresent = selectedIds.includes(asset.id); - if (isPresent) { - updateContext({ - selectedIds: selectedIds.filter((id) => id !== asset.id), - }); - } else { - const clickedIndex = images.findIndex((image) => { - return image.id === asset.id; - }); - if (event.shiftKey && lastSelectedIndex !== -1) { - const startIndex = Math.min(clickedIndex, lastSelectedIndex); - const endIndex = Math.max(clickedIndex, lastSelectedIndex); - if (startIndex >= 0 && endIndex < images.length) { - const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); - const allSelectedIds = [...selectedIds, ...newSelectedIds]; - const uniqueSelectedIds = [...new Set(allSelectedIds)]; - updateContext({ selectedIds: uniqueSelectedIds }); - } else { - console.warn("Shift-select index out of bounds"); - updateContext({ selectedIds: [...selectedIds, asset.id] }); - } - } else { - updateContext({ selectedIds: [...selectedIds, asset.id] }); - } - setLastSelectedIndex(clickedIndex); - } + const handleSelectionChange = (ids: string[]) => { + updateContext({ selectedIds: ids }); }; useEffect(() => { @@ -160,46 +104,28 @@ export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps) } return ( - <> - - = 0} - index={index} - close={() => setIndex(-1)} +
+ -
- { + setFilters({ + ...filters, + page: filters.page + 1, + }); }} - /> - {hasMore && ( - - )} -
- + disabled={loading} + > + {loading ? "Loading..." : "Load More"} + + )} +
); } diff --git a/src/components/albums/list/AlbumThumbnail.tsx b/src/components/albums/list/AlbumThumbnail.tsx index e8019be..b2973fe 100644 --- a/src/components/albums/list/AlbumThumbnail.tsx +++ b/src/components/albums/list/AlbumThumbnail.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react' +import React, { useMemo } from 'react' import Link from 'next/link'; import { humanizeBytes, humanizeNumber, pluralize } from '@/helpers/string.helper'; @@ -12,16 +12,20 @@ import { Calendar, Camera } from 'lucide-react'; interface IAlbumThumbnailProps { album: IAlbum; - onSelect: (checked: boolean) => void; + onSelect: (albumId: string, isShiftClick?: boolean) => void; selected: boolean; } -export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) { - const [isSelected, setIsSelected] = useState(selected); +export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) { const numberOfDays = useMemo(() => { return differenceInDays(album.lastPhotoDate, album.firstPhotoDate); }, [album.firstPhotoDate, album.lastPhotoDate]); + const handleCheckboxChange = (event: React.MouseEvent) => { + const isShiftClick = event.shiftKey; + onSelect(album.id, isShiftClick); + }; + return (
@@ -54,15 +58,15 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum {numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`}

-
+
-

{album.albumName}

+

{album.albumName}

-

{humanizeNumber(album.faceCount)} { +

{humanizeNumber(album.faceCount)} { pluralize(album.faceCount, 'person', 'people') }

-

{humanizeBytes(parseInt(album.size))}

+

{humanizeBytes(parseInt(album.size))}

diff --git a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx index 548eb80..d0c8159 100644 --- a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx +++ b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx @@ -1,15 +1,10 @@ -import "yet-another-react-lightbox/styles.css"; import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler"; import type { IAsset } from "@/types/asset"; -import React, { type MouseEvent, useEffect, useMemo, useState } from "react"; -import { Gallery } from "react-grid-gallery"; -import Lightbox from "yet-another-react-lightbox"; -import Captions from "yet-another-react-lightbox/plugins/captions"; +import React, { useEffect, useMemo, useState } from "react"; import { CalendarArrowUp, Hourglass } from "lucide-react"; -import Video from "yet-another-react-lightbox/plugins/video"; import { useConfig } from "@/contexts/ConfigContext"; -import LazyGridImage from "@/components/ui/lazy-grid-image"; +import AssetGrid from "@/components/shared/AssetGrid"; export default function PotentialAlbumsAssets() { const { exImmichUrl } = useConfig(); @@ -24,9 +19,6 @@ export default function PotentialAlbumsAssets() { const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); - const [index, setIndex] = useState(-1); - const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); - const fetchAssets = async () => { if (!startDate) return; setLoading(true); @@ -40,7 +32,7 @@ export default function PotentialAlbumsAssets() { .finally(() => setLoading(false)); }; - const images = useMemo(() => { + const processedAssets = useMemo(() => { const sortedAssets = [...assets].sort((a, b) => { try { return new Date(a.dateTimeOriginal ?? 0).getTime() - new Date(b.dateTimeOriginal ?? 0).getTime(); @@ -49,90 +41,23 @@ export default function PotentialAlbumsAssets() { } }); - return sortedAssets.map((p) => ({ - ...p, - src: p.url as string, - original: p.previewUrl as string, - width: p.exifImageWidth as number, - height: p.exifImageHeight as number, - isSelected: selectedIds.includes(p.id), - orientation: 1, + return sortedAssets.map((asset) => ({ + ...asset, tags: [ { title: "Immich Link", value: ( - + Open in Immich ), }, ], })); - }, [assets, selectedIds, exImmichUrl]); - - - const slides = useMemo( - () => - images.map(({ original, width, height, type, videoURL}) => ({ - src: original, - width, - height, - type: (type === "VIDEO" ? "video" : "image") as any, - sources: - type === "VIDEO" - ? [ - { - src: videoURL, - type: "video/mp4", - }, - ] - : [], - })), - [images] - ); - - const handleClick = (idx: number, asset: IAsset, event: MouseEvent) => { - if (selectedIds.length > 0 && (event.ctrlKey || event.metaKey || event.shiftKey)) { - handleSelect(idx, asset, event); - } else { - setIndex(idx); - } - }; - - const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent) => { - event.stopPropagation(); - const isPresent = selectedIds.includes(asset.id); - - if (isPresent) { - if (event.ctrlKey || event.metaKey || selectedIds.length === 1) { - updateContext({ - selectedIds: selectedIds.filter((id) => id !== asset.id), - }); - } else if (!event.shiftKey) { - updateContext({ selectedIds: [asset.id] }); - } - } else { - const clickedIndex = images.findIndex((image) => image.id === asset.id); - - if (event.shiftKey && lastSelectedIndex !== -1) { - const startIndex = Math.min(clickedIndex, lastSelectedIndex); - const endIndex = Math.max(clickedIndex, lastSelectedIndex); - if (startIndex >= 0 && endIndex < images.length) { - const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); - const combined = event.ctrlKey || event.metaKey ? [...selectedIds, ...newSelectedIds] : newSelectedIds; - const uniqueSelectedIds = [...new Set(combined)]; - updateContext({ selectedIds: uniqueSelectedIds }); - } else { - updateContext({ selectedIds: [...selectedIds, asset.id] }); - } - } else if (event.ctrlKey || event.metaKey) { - updateContext({ selectedIds: [...selectedIds, asset.id] }); - } else { - updateContext({ selectedIds: [asset.id] }); - } - setLastSelectedIndex(clickedIndex); - } + }, [assets, exImmichUrl]); + const handleSelectionChange = (ids: string[]) => { + updateContext({ selectedIds: ids }); }; useEffect(() => { @@ -158,33 +83,15 @@ export default function PotentialAlbumsAssets() {

); + return ( - <> - = 0} - index={index} - close={() => setIndex(-1)} +
+ -
- -
- +
); } 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/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx index e122784..a60b2b4 100644 --- a/src/components/assets/missing-location/MissingLocationAssets.tsx +++ b/src/components/assets/missing-location/MissingLocationAssets.tsx @@ -1,9 +1,5 @@ -import "yet-another-react-lightbox/styles.css"; import { IAsset } from "@/types/asset"; -import React, { useEffect, useMemo, useState, MouseEvent } from "react"; -import { Gallery } from "react-grid-gallery"; -import Lightbox from "yet-another-react-lightbox"; -import Captions from "yet-another-react-lightbox/plugins/captions"; +import React, { useEffect, useMemo, useState } from "react"; import { CalendarArrowUp, Hourglass, @@ -11,7 +7,7 @@ import { import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; import { listMissingLocationAssets } from "@/handlers/api/asset.handler"; import { useConfig } from "@/contexts/ConfigContext"; -import LazyGridImage from "@/components/ui/lazy-grid-image"; +import AssetGrid from "@/components/shared/AssetGrid"; interface IProps { groupBy: "date" | "album"; @@ -30,9 +26,6 @@ export default function MissingLocationAssets({ groupBy }: IProps) { const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); - const [index, setIndex] = useState(-1); - const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); - const fetchAssets = async () => { if (!startDate && !albumId) return; setLoading(true); @@ -46,7 +39,7 @@ export default function MissingLocationAssets({ groupBy }: IProps) { .finally(() => setLoading(false)); }; - const images = useMemo(() => { + const processedAssets = useMemo(() => { let sortedAssets: IAsset[]; try { if (sortOrder === "desc") @@ -58,76 +51,23 @@ export default function MissingLocationAssets({ groupBy }: IProps) { sortedAssets = [...assets]; } - return sortedAssets. - map((p) => ({ - ...p, - src: p.url as string, - original: p.previewUrl as string, - width: p.exifImageWidth as number, - height: p.exifImageHeight as number, - isSelected: selectedIds.includes(p.id), - orientation: 1, - tags: [ - { - title: "Immich Link", - value: ( - - Open in Immich - - ), - }, - ], - })); - }, [assets, selectedIds, sortOrder, exImmichUrl]); + return sortedAssets.map((asset) => ({ + ...asset, + tags: [ + { + title: "Immich Link", + value: ( + + Open in Immich + + ), + }, + ], + })); + }, [assets, sortOrder, exImmichUrl]); - const slides = useMemo( - () => - images.map(({ original, width, height }) => ({ - src: original, - width, - height, - })), - [images] - ); - - const handleClick = (idx: number, asset: IAsset, event: MouseEvent) => { - if (selectedIds.length > 0) { - handleSelect(idx, asset, event); - } - else - { - setIndex(idx); - } - }; - - const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent) => { - event.stopPropagation(); - const isPresent = selectedIds.includes(asset.id); - if (isPresent) { - updateContext({ - selectedIds: selectedIds.filter((id) => id !== asset.id), - }); - } else { - const clickedIndex = images.findIndex((image) => { - return image.id === asset.id; - }); - if (event.shiftKey && lastSelectedIndex !== -1) { - const startIndex = Math.min(clickedIndex, lastSelectedIndex); - const endIndex = Math.max(clickedIndex, lastSelectedIndex); - if (startIndex >= 0 && endIndex < images.length) { - const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); - const allSelectedIds = [...selectedIds, ...newSelectedIds]; - const uniqueSelectedIds = [...new Set(allSelectedIds)]; - updateContext({ selectedIds: uniqueSelectedIds }); - } else { - console.warn("Shift-select index out of bounds"); - updateContext({ selectedIds: [...selectedIds, asset.id] }); - } - } else { - updateContext({ selectedIds: [...selectedIds, asset.id] }); - } - setLastSelectedIndex(clickedIndex); - } + const handleSelectionChange = (ids: string[]) => { + updateContext({ selectedIds: ids }); }; useEffect(() => { @@ -155,30 +95,13 @@ export default function MissingLocationAssets({ groupBy }: IProps) { ); return ( - <> - = 0} - index={index} - close={() => setIndex(-1)} +
+ -
- -
- +
); } diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/RecentSearches.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/RecentSearches.tsx index 7c12eeb..9739654 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/RecentSearches.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/RecentSearches.tsx @@ -1,6 +1,5 @@ import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { getRecentSearchesByType, RecentSearch } from "@/lib/utils"; +import { getRecentSearchesByType, RecentSearch, cn } from "@/lib/utils"; import { IPlace } from "@/types/common"; import { Clock, MapPin } from "lucide-react"; import React from "react"; @@ -9,9 +8,10 @@ interface RecentSearchesProps { searchType: RecentSearch['searchType']; onSelect: (place: IPlace) => void; selectedPlace?: IPlace | null; + className?: string; } -export default function RecentSearches({ searchType, onSelect, selectedPlace }: RecentSearchesProps) { +export default function RecentSearches({ searchType, onSelect, selectedPlace, className }: RecentSearchesProps) { const recentSearches = getRecentSearchesByType(searchType); if (recentSearches.length === 0) { @@ -32,7 +32,7 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }: }; return ( -
+

Recent Searches

@@ -50,7 +50,7 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }: "hover:bg-zinc-200 dark:hover:bg-zinc-800 flex justify-between items-center px-3 py-2 rounded-lg cursor-pointer border", { "bg-zinc-200 dark:bg-zinc-800 border-primary": - selectedPlace && + selectedPlace && selectedPlace.name === search.name && selectedPlace.latitude === search.latitude && selectedPlace.longitude === search.longitude, @@ -60,7 +60,7 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }:
-

{search.name}

+

{search.name}

@@ -76,4 +76,4 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }:
); -} \ No newline at end of file +} diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx index 374550d..3c22822 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx @@ -54,7 +54,7 @@ export default function TagMissingLocationDialog({ Tagging a location will add the location to the selected {selectedIds.length} asset(s). - + Open Street Map Immich Geo @@ -71,16 +71,17 @@ export default function TagMissingLocationDialog({ -
+
{/* Recent Searches */} - { setMapPosition(place); }} selectedPlace={mapPosition.name ? mapPosition : null} + className="w-[500px]" // Matching the width of the map /> - + void; } -export default function AssetGrid({ assets, isInternal = true, selectable = false, onSelectionChange }: AssetGridProps) { +interface AssetGridRef { + getSelectedIds: () => string[]; + selectAll: () => void; + unselectAll: () => void; +} + +const AssetGrid = forwardRef(({ assets, isInternal = true, selectable = false, onSelectionChange }, ref) => { const [index, setIndex] = useState(-1); - const [selectedIds, setSelectedIds] = useState([]); const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); + + // Use context for selection state + const { selectedIds, updateContext } = usePhotoSelectionContext(); + + useImperativeHandle(ref, () => ({ + getSelectedIds: () => selectedIds, + selectAll: () => { + const allIds = assets.map((asset) => asset.id); + updateContext({ selectedIds: allIds }); + onSelectionChange?.(allIds); + }, + unselectAll: () => { + updateContext({ selectedIds: [] }); + onSelectionChange?.([]); + }, + }), [assets, selectedIds, updateContext]); const handleClick = (index: number, asset: IAsset, event: React.MouseEvent) => { @@ -35,8 +57,9 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals event.stopPropagation(); const isPresent = selectedIds.includes(asset.id); if (isPresent) { - setSelectedIds(selectedIds.filter((id) => id !== asset.id)); - onSelectionChange?.(selectedIds.filter((id) => id !== asset.id)); + const newSelectedIds = selectedIds.filter((id) => id !== asset.id); + updateContext({ selectedIds: newSelectedIds }); + onSelectionChange?.(newSelectedIds); } else { const clickedIndex = images.findIndex((image) => { return image.id === asset.id; @@ -44,14 +67,15 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals if (event.shiftKey) { const startIndex = Math.min(clickedIndex, lastSelectedIndex); const endIndex = Math.max(clickedIndex, lastSelectedIndex); - const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); - const allSelectedIds = [...selectedIds, ...newSelectedIds]; + const rangeSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); + const allSelectedIds = [...selectedIds, ...rangeSelectedIds]; const uniqueSelectedIds = [...new Set(allSelectedIds)]; - setSelectedIds(uniqueSelectedIds); + updateContext({ selectedIds: uniqueSelectedIds }); onSelectionChange?.(uniqueSelectedIds); } else { - setSelectedIds([...selectedIds, asset.id]); - onSelectionChange?.([...selectedIds, asset.id]); + const newSelectedIds = [...selectedIds, asset.id]; + updateContext({ selectedIds: newSelectedIds }); + onSelectionChange?.(newSelectedIds); } setLastSelectedIndex(clickedIndex); } @@ -93,7 +117,7 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals const handleEsc = (event: KeyboardEvent) => { if (event.key === "Escape") { - setSelectedIds([]); + updateContext({ selectedIds: [] }); onSelectionChange?.([]); } }; @@ -122,4 +146,6 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals />
); -} +}) +AssetGrid.displayName = "AssetGrid"; +export default AssetGrid; diff --git a/src/components/shared/AssetGridExample.tsx b/src/components/shared/AssetGridExample.tsx new file mode 100644 index 0000000..e1913c7 --- /dev/null +++ b/src/components/shared/AssetGridExample.tsx @@ -0,0 +1,87 @@ +import React, { useRef } from 'react'; +import { IAsset } from '@/types/asset'; +import VirtualizedAssetGrid from './VirtualizedAssetGrid'; +import ResponsiveVirtualizedAssetGrid from './ResponsiveVirtualizedAssetGrid'; +import { Button } from '@/components/ui/button'; + +interface AssetGridExampleProps { + assets: IAsset[]; + onSelectionChange?: (ids: string[]) => void; +} + +export default function AssetGridExample({ assets, onSelectionChange }: AssetGridExampleProps) { + const gridRef = useRef(null); + + const handleSelectAll = () => { + gridRef.current?.selectAll(); + }; + + const handleUnselectAll = () => { + gridRef.current?.unselectAll(); + }; + + return ( +
+
+ + +
+ + {/* Example 1: Basic Virtualized Grid with fixed dimensions */} +
+

Fixed Size Virtualized Grid

+
+ +
+
+ + {/* Example 2: Responsive Virtualized Grid */} +
+

Responsive Virtualized Grid

+
+ +
+
+ + {/* Example 3: Responsive Grid with different target sizes */} +
+

Small Items Virtualized Grid

+
+ +
+
+
+ ); +} diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx index 701d509..072eb13 100644 --- a/src/components/shared/FloatingBar.tsx +++ b/src/components/shared/FloatingBar.tsx @@ -1,12 +1,14 @@ +import clsx from 'clsx'; import React from 'react' interface FloatingBarProps { children: React.ReactNode; + className?: string; } -export default function FloatingBar({ children }: FloatingBarProps) { +export default function FloatingBar({ children, className }: FloatingBarProps) { return ( -
+
{children}
) diff --git a/src/components/shared/ResponsiveVirtualizedAssetGrid.tsx b/src/components/shared/ResponsiveVirtualizedAssetGrid.tsx new file mode 100644 index 0000000..95d0294 --- /dev/null +++ b/src/components/shared/ResponsiveVirtualizedAssetGrid.tsx @@ -0,0 +1,254 @@ +import "yet-another-react-lightbox/styles.css"; + +import { IAsset } from '@/types/asset'; +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useCallback, useRef } from 'react' +import Lightbox from 'yet-another-react-lightbox'; +import Download from "yet-another-react-lightbox/plugins/download"; +import Video from "yet-another-react-lightbox/plugins/video"; +import { usePhotoSelectionContext } from '@/contexts/PhotoSelectionContext'; +import { FixedSizeGrid as Grid } from 'react-window'; +import LazyImage from '../ui/lazy-image'; +import { humanizeDuration } from '@/helpers/string.helper'; +import { PlayIcon } from '@radix-ui/react-icons'; +import { useGridDimensions } from '@/hooks'; + +interface ResponsiveVirtualizedAssetGridProps { + assets: IAsset[]; + isInternal?: boolean; + selectable?: boolean; + onSelectionChange?: (ids: string[]) => void; + targetItemSize?: number; + gap?: number; + minColumns?: number; + maxColumns?: number; + className?: string; +} + +interface ResponsiveVirtualizedAssetGridRef { + getSelectedIds: () => string[]; + selectAll: () => void; + unselectAll: () => void; +} + +const ResponsiveVirtualizedAssetGrid = forwardRef(({ + assets, + isInternal = true, + selectable = false, + onSelectionChange, + targetItemSize = 200, + gap = 8, + minColumns = 2, + maxColumns = 8, + className = "" +}, ref) => { + const [index, setIndex] = useState(-1); + const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + + // Use context for selection state + const { selectedIds, updateContext } = usePhotoSelectionContext(); + + // Calculate grid dimensions + const gridDimensions = useGridDimensions({ + containerWidth: containerSize.width, + containerHeight: containerSize.height, + itemSize: targetItemSize, + gap, + minColumns, + maxColumns + }); + + // Update row count when assets change + useEffect(() => { + gridDimensions.updateRowCount(assets.length); + }, [assets.length, gridDimensions]); + + // Resize observer to handle container size changes + useEffect(() => { + if (!containerRef.current) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setContainerSize({ width, height }); + } + }); + + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useImperativeHandle(ref, () => ({ + getSelectedIds: () => selectedIds, + selectAll: () => { + const allIds = assets.map((asset) => asset.id); + updateContext({ selectedIds: allIds }); + onSelectionChange?.(allIds); + }, + unselectAll: () => { + updateContext({ selectedIds: [] }); + onSelectionChange?.([]); + }, + }), [assets, selectedIds, updateContext]); + + const slides = useMemo(() => { + return assets.map((asset) => ({ + ...asset, + orientation: 1, + src: asset.previewUrl as string, + type: (asset.type === "VIDEO" ? "video" : "image") as any, + sources: + asset.type === "VIDEO" + ? [ + { + src: asset.downloadUrl as string, + type: "video/mp4", + }, + ] + : undefined, + height: asset.exifImageHeight as number, + width: asset.exifImageWidth as number, + downloadUrl: asset.downloadUrl as string, + })); + }, [assets]); + + const handleClick = useCallback((index: number, asset: IAsset, event: React.MouseEvent) => { + if (selectedIds.length > 0) { + handleSelect(index, asset, event); + } else { + setIndex(index); + } + }, [selectedIds]); + + const handleSelect = useCallback((_idx: number, asset: IAsset, event: React.MouseEvent) => { + event.stopPropagation(); + const isPresent = selectedIds.includes(asset.id); + if (isPresent) { + const newSelectedIds = selectedIds.filter((id) => id !== asset.id); + updateContext({ selectedIds: newSelectedIds }); + onSelectionChange?.(newSelectedIds); + } else { + const clickedIndex = assets.findIndex((a) => a.id === asset.id); + if (event.shiftKey) { + const startIndex = Math.min(clickedIndex, lastSelectedIndex); + const endIndex = Math.max(clickedIndex, lastSelectedIndex); + const rangeSelectedIds = assets.slice(startIndex, endIndex + 1).map((a) => a.id); + const allSelectedIds = [...selectedIds, ...rangeSelectedIds]; + const uniqueSelectedIds = [...new Set(allSelectedIds)]; + updateContext({ selectedIds: uniqueSelectedIds }); + onSelectionChange?.(uniqueSelectedIds); + } else { + const newSelectedIds = [...selectedIds, asset.id]; + updateContext({ selectedIds: newSelectedIds }); + onSelectionChange?.(newSelectedIds); + } + setLastSelectedIndex(clickedIndex); + } + }, [selectedIds, assets, lastSelectedIndex, updateContext, onSelectionChange]); + + const handleEsc = useCallback((event: KeyboardEvent) => { + if (event.key === "Escape") { + updateContext({ selectedIds: [] }); + onSelectionChange?.([]); + } + }, [updateContext, onSelectionChange]); + + useEffect(() => { + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [handleEsc]); + + // Cell renderer for React Window + const Cell = useCallback(({ columnIndex, rowIndex, style }: { columnIndex: number; rowIndex: number; style: React.CSSProperties }) => { + const assetIndex = rowIndex * gridDimensions.columnCount + columnIndex; + const asset = assets[assetIndex]; + + if (!asset) { + return
; + } + + const isSelected = selectedIds.includes(asset.id); + + return ( +
handleClick(assetIndex, asset, e)} + > +
+ + {asset.type === "VIDEO" && ( +
+ + {asset.duration && ( + + {humanizeDuration(asset.duration)} + + )} +
+ )} + {selectable && ( +
+ )} +
+
+ ); + }, [assets, selectedIds, gridDimensions.columnCount, handleClick, selectable, gap]); + + // Don't render grid until we have container dimensions + if (containerSize.width === 0 || gridDimensions.rowCount === 0) { + return ( +
+
+
Loading...
+
+
+ ); + } + + return ( +
+ = 0} + index={index} + close={() => setIndex(-1)} + /> + + {Cell} + +
+ ); +}); + +ResponsiveVirtualizedAssetGrid.displayName = "ResponsiveVirtualizedAssetGrid"; +export default ResponsiveVirtualizedAssetGrid; diff --git a/src/components/shared/VirtualizedAssetGrid.tsx b/src/components/shared/VirtualizedAssetGrid.tsx new file mode 100644 index 0000000..c038131 --- /dev/null +++ b/src/components/shared/VirtualizedAssetGrid.tsx @@ -0,0 +1,205 @@ +import "yet-another-react-lightbox/styles.css"; + +import { IAsset } from '@/types/asset'; +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useCallback } from 'react' +import Lightbox from 'yet-another-react-lightbox'; +import Download from "yet-another-react-lightbox/plugins/download"; +import Video from "yet-another-react-lightbox/plugins/video"; +import { usePhotoSelectionContext } from '@/contexts/PhotoSelectionContext'; +import { FixedSizeGrid as Grid } from 'react-window'; +import LazyImage from '../ui/lazy-image'; +import { humanizeDuration } from '@/helpers/string.helper'; +import { PlayIcon } from '@radix-ui/react-icons'; + +interface VirtualizedAssetGridProps { + assets: IAsset[]; + isInternal?: boolean; + selectable?: boolean; + onSelectionChange?: (ids: string[]) => void; + columnCount?: number; + itemSize?: number; + height?: number; +} + +interface VirtualizedAssetGridRef { + getSelectedIds: () => string[]; + selectAll: () => void; + unselectAll: () => void; +} + +const VirtualizedAssetGrid = forwardRef(({ + assets, + isInternal = true, + selectable = false, + onSelectionChange, + columnCount = 5, + itemSize = 200, + height = 600 +}, ref) => { + const [index, setIndex] = useState(-1); + const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); + + // Use context for selection state + const { selectedIds, updateContext } = usePhotoSelectionContext(); + + useImperativeHandle(ref, () => ({ + getSelectedIds: () => selectedIds, + selectAll: () => { + const allIds = assets.map((asset) => asset.id); + updateContext({ selectedIds: allIds }); + onSelectionChange?.(allIds); + }, + unselectAll: () => { + updateContext({ selectedIds: [] }); + onSelectionChange?.([]); + }, + }), [assets, selectedIds, updateContext]); + + const slides = useMemo(() => { + return assets.map((asset) => ({ + ...asset, + orientation: 1, + src: asset.previewUrl as string, + type: (asset.type === "VIDEO" ? "video" : "image") as any, + sources: + asset.type === "VIDEO" + ? [ + { + src: asset.downloadUrl as string, + type: "video/mp4", + }, + ] + : undefined, + height: asset.exifImageHeight as number, + width: asset.exifImageWidth as number, + downloadUrl: asset.downloadUrl as string, + })); + }, [assets]); + + const handleClick = useCallback((index: number, asset: IAsset, event: React.MouseEvent) => { + if (selectedIds.length > 0) { + handleSelect(index, asset, event); + } else { + setIndex(index); + } + }, [selectedIds]); + + const handleSelect = useCallback((_idx: number, asset: IAsset, event: React.MouseEvent) => { + event.stopPropagation(); + const isPresent = selectedIds.includes(asset.id); + if (isPresent) { + const newSelectedIds = selectedIds.filter((id) => id !== asset.id); + updateContext({ selectedIds: newSelectedIds }); + onSelectionChange?.(newSelectedIds); + } else { + const clickedIndex = assets.findIndex((a) => a.id === asset.id); + if (event.shiftKey) { + const startIndex = Math.min(clickedIndex, lastSelectedIndex); + const endIndex = Math.max(clickedIndex, lastSelectedIndex); + const rangeSelectedIds = assets.slice(startIndex, endIndex + 1).map((a) => a.id); + const allSelectedIds = [...selectedIds, ...rangeSelectedIds]; + const uniqueSelectedIds = [...new Set(allSelectedIds)]; + updateContext({ selectedIds: uniqueSelectedIds }); + onSelectionChange?.(uniqueSelectedIds); + } else { + const newSelectedIds = [...selectedIds, asset.id]; + updateContext({ selectedIds: newSelectedIds }); + onSelectionChange?.(newSelectedIds); + } + setLastSelectedIndex(clickedIndex); + } + }, [selectedIds, assets, lastSelectedIndex, updateContext, onSelectionChange]); + + const handleEsc = useCallback((event: KeyboardEvent) => { + if (event.key === "Escape") { + updateContext({ selectedIds: [] }); + onSelectionChange?.([]); + } + }, [updateContext, onSelectionChange]); + + useEffect(() => { + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [handleEsc]); + + // Calculate grid dimensions + const rowCount = Math.ceil(assets.length / columnCount); + const width = columnCount * itemSize; + + // Cell renderer for React Window + const Cell = useCallback(({ columnIndex, rowIndex, style }: { columnIndex: number; rowIndex: number; style: React.CSSProperties }) => { + const assetIndex = rowIndex * columnCount + columnIndex; + const asset = assets[assetIndex]; + + if (!asset) { + return
; + } + + const isSelected = selectedIds.includes(asset.id); + + return ( +
handleClick(assetIndex, asset, e)} + > +
+ + {asset.type === "VIDEO" && ( +
+ + {asset.duration && ( + + {humanizeDuration(asset.duration)} + + )} +
+ )} + {selectable && ( +
+ )} +
+
+ ); + }, [assets, selectedIds, columnCount, handleClick, selectable]); + + return ( +
+ = 0} + index={index} + close={() => setIndex(-1)} + /> + + {Cell} + +
+ ); +}); + +VirtualizedAssetGrid.displayName = "VirtualizedAssetGrid"; +export default VirtualizedAssetGrid; diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..6d0fd00 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,197 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { cn } from "@/lib/utils" +import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx new file mode 100644 index 0000000..cc3aeb6 --- /dev/null +++ b/src/components/ui/data-table.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import { ChevronDown } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { + TableRoot, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + onRowSelectionChange?: (selectedIds: string[]) => void + getRowId?: (row: TData) => string + searchValue?: string + onSearchChange?: (value: string) => void +} + +export interface DataTableRef { + selectRows: (rowIds: string[]) => void + selectAllRows: () => void + deselectAllRows: () => void + getSelectedRows: () => string[] + clearSelection: () => void +} + +const DataTable = React.forwardRef, DataTableProps>( + ({ columns, data, onRowSelectionChange, getRowId, searchValue, onSearchChange }, ref) => { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [rowSelection, setRowSelection] = React.useState>({}) + + const table = useReactTable({ + data, + columns, + getRowId: getRowId || ((row: any) => row.id), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + // Add these options to prevent potential issues + enableRowSelection: true, + enableMultiRowSelection: true, + enableSorting: true, + enableFilters: true, + enableColumnFilters: true, + }) + + // Expose methods via ref + React.useImperativeHandle(ref, () => ({ + selectRows: (rowIds: string[]) => { + const newSelection: Record = {} + rowIds.forEach(id => { + newSelection[id] = true + }) + setRowSelection(newSelection) + }, + selectAllRows: () => { + const allRowIds = table.getRowModel().rows.map(row => row.id) + const newSelection: Record = {} + allRowIds.forEach(id => { + newSelection[id] = true + }) + setRowSelection(newSelection) + }, + deselectAllRows: () => { + setRowSelection({}) + }, + getSelectedRows: () => { + return Object.keys(rowSelection).filter(id => rowSelection[id]) + }, + clearSelection: () => { + setRowSelection({}) + }, + }), [table]) + + // Sync external search with table filter + React.useEffect(() => { + if (searchValue !== undefined) { + table.getColumn("albumName")?.setFilterValue(searchValue) + } + }, [searchValue, table]) + + // Notify parent of selection changes + React.useEffect(() => { + const selectedRowIds = Object.keys(rowSelection).filter(id => rowSelection[id]) + onRowSelectionChange?.(selectedRowIds) + }, [rowSelection, onRowSelectionChange]) + + const handleShowAllColumns = () => { + table.toggleAllColumnsVisible(true) + } + + const handleHideAllColumns = () => { + table.toggleAllColumnsVisible(false) + } + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + + + header.column.toggleVisibility()} + > + {header.column.getIsVisible() ? "Hide Column" : "Show Column"} + + + + Show All Columns + + + Hide All Columns + + +
+ Column Visibility +
+ {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + column.toggleVisibility()} + className="flex items-center justify-between" + > + {column.id} +
+ + ) + })} + + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + + +
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+ + +
+
+
+ ) + } +) + +DataTable.displayName = "DataTable" + +export default DataTable \ No newline at end of file diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index ebe8d2a..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 } from "lucide-react"; +import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react"; export const sidebarNavs = [ // { @@ -41,6 +41,18 @@ export const sidebarNavs = [ title: "Geo Heatmap", link: "/assets/geo-heatmap", icon: , - } + }, + // Empty Videos + { + title: "Empty Videos", + link: "/assets/empty-videos", + icon:
+ } rightComponent={
- {!!selectedAlbumsIds.length && ( - <> - - - - - - - )} setSearch(e.target.value)} /> + +
+ + +
+ handleMaxDurationChange(e.target.value)} + onBlur={handleMaxDurationBlur} + className="w-20 h-8" + /> + s +
+ + + + {/* Asset Count */} +
+ {humanizeNumber(assets.length)} + {' '} + +
+
+ ) + } + /> + + {renderContent()} + {selectedAssets.length > 0 && + +
+

+ {contextState.selectedIds.length} Selected +

+
+ {contextState.selectedIds.length === contextState.assets.length ? ( + + ) : ( + + )} +
+ + + +
+
+
+ } +
+ + ) +} diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index f037bd7..98a9613 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -94,7 +94,7 @@ export default function MissingLocations() { } return ( - +
+
{geminiEnabled ? ( <> 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; +}