Merge pull request #173 from varun-raj/feat-august-release

Feat: August release
This commit is contained in:
Varun Raj Manoharan authored and GitHub committed 2025-08-23 18:11:13 +05:30
commit 314f7aeff0
39 files changed
+3208 -433

No files matched your search

+9 -1
View File
@@ -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`
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
+9 -2
View File
@@ -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.
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.
BIN
View File
Binary file not shown.
+155
View File
@@ -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';
<VirtualizedAssetGrid
assets={assets}
selectable={true}
onSelectionChange={handleSelectionChange}
columnCount={5}
itemSize={200}
height={600}
/>
```
**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';
<ResponsiveVirtualizedAssetGrid
assets={assets}
selectable={true}
onSelectionChange={handleSelectionChange}
targetItemSize={200}
gap={8}
minColumns={2}
maxColumns={8}
className="w-full h-full"
/>
```
**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';
<AssetGrid
assets={assets}
selectable={true}
onSelectionChange={handleSelectionChange}
/>
```
### After:
```tsx
import ResponsiveVirtualizedAssetGrid from '@/components/shared/ResponsiveVirtualizedAssetGrid';
<div style={{ height: '600px', width: '100%' }}>
<ResponsiveVirtualizedAssetGrid
assets={assets}
selectable={true}
onSelectionChange={handleSelectionChange}
targetItemSize={200}
gap={8}
minColumns={2}
maxColumns={8}
/>
</div>
```
**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.
+4 -1
View File
@@ -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",
+236
View File
@@ -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<IAlbum>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "albumThumbnailAssetId",
header: "Thumbnail",
cell: ({ row }) => {
const album = row.original
return (
<div className="w-16 h-16 rounded-md overflow-hidden">
<LazyImage
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)}
alt={album.albumName}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</div>
)
},
enableSorting: false,
},
{
accessorKey: "albumName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Album Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const album = row.original
return (
<Link
href={`/albums/${album.id}`}
className="font-medium hover:underline"
onClick={(e) => {
// Ensure the link click is not blocked by table events
e.stopPropagation()
}}
>
{album.albumName}
</Link>
)
},
},
{
accessorKey: "assetCount",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<Camera className="mr-2 h-4 w-4" />
Assets
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const count = row.getValue("assetCount") as number
return <div className="font-medium">{humanizeNumber(count)}</div>
},
},
{
accessorKey: "faceCount",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<Users className="mr-2 h-4 w-4" />
People
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const count = row.getValue("faceCount") as number
return <div>{humanizeNumber(count)} {pluralize(count, 'person', 'people')}</div>
},
},
{
accessorKey: "size",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<HardDrive className="mr-2 h-4 w-4" />
Size
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const size = row.getValue("size") as string
return <div>{humanizeBytes(parseInt(size))}</div>
},
},
{
accessorKey: "firstPhotoDate",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<Calendar className="mr-2 h-4 w-4" />
Date Range
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const album = row.original
const numberOfDays = differenceInDays(album.lastPhotoDate, album.firstPhotoDate)
return (
<div className="space-y-1">
<div className="text-sm">
{album.firstPhotoDate ? formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy') : ''}
</div>
<div className="text-xs text-muted-foreground">
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString()} days`}
</div>
</div>
)
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("createdAt") as string
return <div>{formatDate(date, 'MMM d, yyyy')}</div>
},
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const album = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/albums/${album.id}`}>
View Album
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
Share Album
</DropdownMenuItem>
<DropdownMenuItem>
Edit Album
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
Delete Album
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
+30 -104
View File
@@ -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: (
<a href={exImmichUrl + "/photos/" + p.id} target="_blank" rel="noopener noreferrer">
<a href={exImmichUrl + "/photos/" + asset.id} target="_blank" rel="noopener noreferrer">
Open in Immich
</a>
),
},
],
}));
}, [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<HTMLElement>) => {
if (selectedIds.length > 0) {
handleSelect(idx, asset, event);
} else {
setIndex(idx);
}
};
const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
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 (
<>
<Lightbox
slides={slides}
plugins={[Captions]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
<div className="w-full p-2 overflow-y-auto max-h-[calc(100vh-60px)]">
<AssetGrid
assets={processedAssets}
isInternal={true}
selectable={true}
onSelectionChange={handleSelectionChange}
/>
<div className="w-full p-2 overflow-y-auto max-h-[calc(100vh-60px)]">
<Gallery
images={images}
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
thumbnailImageComponent={LazyGridImage}
tagStyle={{
color: "white",
fontSize: "12px",
backgroundColor: "rgba(0, 0, 0)",
padding: "2px",
borderRadius: "5px",
{hasMore && (
<Button
variant="outline"
className="w-full mt-4"
onClick={() => {
setFilters({
...filters,
page: filters.page + 1,
});
}}
/>
{hasMore && (
<Button
variant="outline"
className="w-full mt-4"
onClick={() => {
setFilters({
...filters,
page: filters.page + 1,
});
}}
disabled={loading}
>
{loading ? "Loading..." : "Load More"}
</Button>
)}
</div>
</>
disabled={loading}
>
{loading ? "Loading..." : "Load More"}
</Button>
)}
</div>
);
}
+14 -10
View File
@@ -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 (
<div className="border rounded-lg overflow-hidden shadow-lg relative group">
<label className="block relative ">
@@ -39,8 +43,8 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum
{album.firstPhotoDate ? formatDate(album.firstPhotoDate?.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate?.toString(), 'MMM d, yyyy') : ''}
</div>
<Checkbox
defaultChecked={isSelected}
onCheckedChange={onSelect}
checked={selected}
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"
/>
</label>
@@ -54,15 +58,15 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`} <Calendar size={12} />
</p>
</div>
<div className="p-4">
<div className={`p-4 ${selected ? 'bg-blue-500' : ''}`}>
<Link href={`/albums/${album.id}`}>
<h2 className="text-md font-semibold truncate">{album.albumName}</h2>
<h2 className={`text-md font-semibold truncate ${selected ? 'text-white' : ''}`}>{album.albumName}</h2>
</Link>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-700 dark:text-gray-500">{humanizeNumber(album.faceCount)} {
<p className={`text-xs ${selected ? 'text-white' : 'text-gray-700 dark:text-gray-500'}`}>{humanizeNumber(album.faceCount)} {
pluralize(album.faceCount, 'person', 'people')
} </p>
<p className="text-xs text-gray-700 dark:text-gray-500">{humanizeBytes(parseInt(album.size))}</p>
<p className={`text-xs ${selected ? 'text-white' : 'text-gray-700 dark:text-gray-500'}`}>{humanizeBytes(parseInt(album.size))}</p>
</div>
</div>
</div>
@@ -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<string | null>(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: (
<a href={`${exImmichUrl}/photos/${p.id}`} target="_blank" rel="noreferrer">
<a href={`${exImmichUrl}/photos/${asset.id}`} target="_blank" rel="noreferrer">
Open in Immich
</a>
),
},
],
}));
}, [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<HTMLElement>) => {
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<HTMLElement>) => {
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() {
</p>
</div>
);
return (
<>
<Lightbox
slides={slides}
plugins={[Captions, Video]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
<AssetGrid
assets={processedAssets}
isInternal={true}
selectable={true}
onSelectionChange={handleSelectionChange}
/>
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
<Gallery
images={images}
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
thumbnailImageComponent={LazyGridImage}
rowHeight={220}
margin={3}
tagStyle={{
color: "white",
fontSize: "12px",
backgroundColor: "rgba(0, 0, 0, 0.7)",
padding: "2px 4px",
borderRadius: "3px",
}}
/>
</div>
</>
</div>
);
}
@@ -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,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<string | null>(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: (
<a href={exImmichUrl + "/photos/" + p.id} target="_blank" rel="noopener noreferrer">
Open in Immich
</a>
),
},
],
}));
}, [assets, selectedIds, sortOrder, exImmichUrl]);
return sortedAssets.map((asset) => ({
...asset,
tags: [
{
title: "Immich Link",
value: (
<a href={exImmichUrl + "/photos/" + asset.id} target="_blank" rel="noopener noreferrer">
Open in Immich
</a>
),
},
],
}));
}, [assets, sortOrder, exImmichUrl]);
const slides = useMemo(
() =>
images.map(({ original, width, height }) => ({
src: original,
width,
height,
})),
[images]
);
const handleClick = (idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
if (selectedIds.length > 0) {
handleSelect(idx, asset, event);
}
else
{
setIndex(idx);
}
};
const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
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 (
<>
<Lightbox
slides={slides}
plugins={[Captions]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
<AssetGrid
assets={processedAssets}
isInternal={true}
selectable={true}
onSelectionChange={handleSelectionChange}
/>
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
<Gallery
images={images}
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
thumbnailImageComponent={LazyGridImage}
tagStyle={{
color: "white",
fontSize: "12px",
backgroundColor: "rgba(0, 0, 0)",
padding: "2px",
borderRadius: "5px",
}}
/>
</div>
</>
</div>
);
}
@@ -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 (
<div className="mb-4">
<div className={cn("mb-4", className)}>
<div className="flex items-center gap-2 mb-3">
<Clock className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">Recent Searches</h3>
@@ -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 }:
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<MapPin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<p className="text-sm truncate">{search.name}</p>
<p className="text-sm line-clamp-2">{search.name}</p>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-muted-foreground">
@@ -76,4 +76,4 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }:
</div>
</div>
);
}
}
@@ -54,7 +54,7 @@ export default function TagMissingLocationDialog({
Tagging a location will add the location to the selected {selectedIds.length} asset(s).
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="searchOsm" className="border rounded-lg">
<Tabs defaultValue="searchOsm" className="border rounded-lg min-w-0 max-w-full">
<TabsList className="flex justify-between">
<TabsTrigger value="searchOsm" className="w-full">Open Street Map</TabsTrigger>
<TabsTrigger value="search" className="w-full">Immich Geo</TabsTrigger>
@@ -71,16 +71,17 @@ export default function TagMissingLocationDialog({
<TagMissingLocationSearchLatLong onSubmit={onSubmit} onOpenChange={setOpen} location={mapPosition} onLocationChange={setMapPosition} />
</TabsContent>
<TabsContent value="maps">
<div className="flex flex-col gap-6 items-center ">
<div className="flex flex-col gap-6 items-center min-w-0 max-w-full py-4">
{/* Recent Searches */}
<RecentSearches
searchType="map"
<RecentSearches
searchType="map"
onSelect={(place) => {
setMapPosition(place);
}}
selectedPlace={mapPosition.name ? mapPosition : null}
className="w-[500px]" // Matching the width of the map
/>
<LazyMap location={{
latitude: isNaN(mapPosition.latitude) ? 0 : mapPosition.latitude,
longitude: isNaN(mapPosition.longitude) ? 0 : mapPosition.longitude,
+38 -12
View File
@@ -1,12 +1,13 @@
import "yet-another-react-lightbox/styles.css";
import { IAsset } from '@/types/asset';
import React, { useEffect, useMemo, useState } from 'react'
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import Lightbox from 'yet-another-react-lightbox';
import { Gallery } from "react-grid-gallery";
import LazyGridImage from "../ui/lazy-grid-image";
import Download from "yet-another-react-lightbox/plugins/download";
import Video from "yet-another-react-lightbox/plugins/video";
import { usePhotoSelectionContext } from '@/contexts/PhotoSelectionContext';
interface AssetGridProps {
@@ -16,10 +17,31 @@ interface AssetGridProps {
onSelectionChange?: (ids: string[]) => void;
}
export default function AssetGrid({ assets, isInternal = true, selectable = false, onSelectionChange }: AssetGridProps) {
interface AssetGridRef {
getSelectedIds: () => string[];
selectAll: () => void;
unselectAll: () => void;
}
const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ assets, isInternal = true, selectable = false, onSelectionChange }, ref) => {
const [index, setIndex] = useState(-1);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
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<HTMLElement>) => {
@@ -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
/>
</div>
);
}
})
AssetGrid.displayName = "AssetGrid";
export default AssetGrid;
@@ -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<any>(null);
const handleSelectAll = () => {
gridRef.current?.selectAll();
};
const handleUnselectAll = () => {
gridRef.current?.unselectAll();
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<Button onClick={handleSelectAll} variant="outline" size="sm">
Select All
</Button>
<Button onClick={handleUnselectAll} variant="outline" size="sm">
Unselect All
</Button>
</div>
{/* Example 1: Basic Virtualized Grid with fixed dimensions */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2">Fixed Size Virtualized Grid</h3>
<div style={{ height: '600px', width: '100%' }}>
<VirtualizedAssetGrid
ref={gridRef}
assets={assets}
selectable={true}
onSelectionChange={onSelectionChange}
columnCount={5}
itemSize={200}
height={600}
/>
</div>
</div>
{/* Example 2: Responsive Virtualized Grid */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2">Responsive Virtualized Grid</h3>
<div style={{ height: '600px', width: '100%' }}>
<ResponsiveVirtualizedAssetGrid
ref={gridRef}
assets={assets}
selectable={true}
onSelectionChange={onSelectionChange}
targetItemSize={200}
gap={8}
minColumns={2}
maxColumns={8}
className="w-full h-full"
/>
</div>
</div>
{/* Example 3: Responsive Grid with different target sizes */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2">Small Items Virtualized Grid</h3>
<div style={{ height: '400px', width: '100%' }}>
<ResponsiveVirtualizedAssetGrid
ref={gridRef}
assets={assets}
selectable={true}
onSelectionChange={onSelectionChange}
targetItemSize={120}
gap={4}
minColumns={3}
maxColumns={12}
className="w-full h-full"
/>
</div>
</div>
</div>
);
}
+4 -2
View File
@@ -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 (
<div className='absolute bottom-10 left-0 right-0 max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border shadow-lg'>
<div className={clsx('fixed bottom-10 left-0 right-0 max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border shadow-lg z-50', className)}>
{children}
</div>
)
@@ -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<ResponsiveVirtualizedAssetGridRef, ResponsiveVirtualizedAssetGridProps>(({
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<HTMLDivElement>(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<HTMLElement>) => {
if (selectedIds.length > 0) {
handleSelect(index, asset, event);
} else {
setIndex(index);
}
}, [selectedIds]);
const handleSelect = useCallback((_idx: number, asset: IAsset, event: React.MouseEvent<HTMLElement>) => {
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 <div style={style} />;
}
const isSelected = selectedIds.includes(asset.id);
return (
<div
style={{
...style,
padding: `${gap / 2}px`,
}}
className={`relative ${isSelected ? 'ring-2 ring-blue-500 rounded-lg' : ''}`}
onClick={(e) => handleClick(assetIndex, asset, e)}
>
<div className="relative w-full h-full rounded-lg overflow-hidden">
<LazyImage
src={asset.url as string}
alt={asset.originalFileName || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
{asset.type === "VIDEO" && (
<div className="absolute bottom-2 right-2 bg-black/50 p-1 rounded-full flex items-center gap-1">
<PlayIcon className="w-3 h-3 text-white" />
{asset.duration && (
<span className="text-xs text-white">
{humanizeDuration(asset.duration)}
</span>
)}
</div>
)}
{selectable && (
<div
className={`absolute top-2 left-2 w-4 h-4 rounded-full border-2 ${
isSelected
? 'bg-blue-500 border-blue-500'
: 'bg-white/80 border-gray-300'
}`}
/>
)}
</div>
</div>
);
}, [assets, selectedIds, gridDimensions.columnCount, handleClick, selectable, gap]);
// Don't render grid until we have container dimensions
if (containerSize.width === 0 || gridDimensions.rowCount === 0) {
return (
<div ref={containerRef} className={`w-full h-full ${className}`}>
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Loading...</div>
</div>
</div>
);
}
return (
<div ref={containerRef} className={`w-full h-full ${className}`}>
<Lightbox
slides={slides}
plugins={[Download, Video]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
<Grid
columnCount={gridDimensions.columnCount}
columnWidth={gridDimensions.itemSize}
height={containerSize.height}
rowCount={gridDimensions.rowCount}
rowHeight={gridDimensions.itemSize}
width={containerSize.width}
>
{Cell}
</Grid>
</div>
);
});
ResponsiveVirtualizedAssetGrid.displayName = "ResponsiveVirtualizedAssetGrid";
export default ResponsiveVirtualizedAssetGrid;
@@ -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<VirtualizedAssetGridRef, VirtualizedAssetGridProps>(({
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<HTMLElement>) => {
if (selectedIds.length > 0) {
handleSelect(index, asset, event);
} else {
setIndex(index);
}
}, [selectedIds]);
const handleSelect = useCallback((_idx: number, asset: IAsset, event: React.MouseEvent<HTMLElement>) => {
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 <div style={style} />;
}
const isSelected = selectedIds.includes(asset.id);
return (
<div
style={style}
className={`relative p-1 ${isSelected ? 'ring-2 ring-blue-500' : ''}`}
onClick={(e) => handleClick(assetIndex, asset, e)}
>
<div className="relative w-full h-full">
<LazyImage
src={asset.url as string}
alt={asset.originalFileName || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '4px',
}}
/>
{asset.type === "VIDEO" && (
<div className="absolute bottom-2 right-2 bg-black/50 p-1 rounded-full flex items-center gap-1">
<PlayIcon className="w-3 h-3 text-white" />
{asset.duration && (
<span className="text-xs text-white">
{humanizeDuration(asset.duration)}
</span>
)}
</div>
)}
{selectable && (
<div
className={`absolute top-2 left-2 w-4 h-4 rounded-full border-2 ${
isSelected
? 'bg-blue-500 border-blue-500'
: 'bg-white/80 border-gray-300'
}`}
/>
)}
</div>
</div>
);
}, [assets, selectedIds, columnCount, handleClick, selectable]);
return (
<div>
<Lightbox
slides={slides}
plugins={[Download, Video]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
<Grid
columnCount={columnCount}
columnWidth={itemSize}
height={height}
rowCount={rowCount}
rowHeight={itemSize}
width={width}
>
{Cell}
</Grid>
</div>
);
});
VirtualizedAssetGrid.displayName = "VirtualizedAssetGrid";
export default VirtualizedAssetGrid;
+197
View File
@@ -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<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+262
View File
@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
onRowSelectionChange?: (selectedIds: string[]) => void
getRowId?: (row: TData) => string
searchValue?: string
onSearchChange?: (value: string) => void
}
export interface DataTableRef<TData> {
selectRows: (rowIds: string[]) => void
selectAllRows: () => void
deselectAllRows: () => void
getSelectedRows: () => string[]
clearSelection: () => void
}
const DataTable = React.forwardRef<DataTableRef<any>, DataTableProps<any, any>>(
({ columns, data, onRowSelectionChange, getRowId, searchValue, onSearchChange }, ref) => {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({})
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<string, boolean> = {}
rowIds.forEach(id => {
newSelection[id] = true
})
setRowSelection(newSelection)
},
selectAllRows: () => {
const allRowIds = table.getRowModel().rows.map(row => row.id)
const newSelection: Record<string, boolean> = {}
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 (
<div className="w-full">
<div className="overflow-hidden rounded-md border">
<TableRoot>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<ContextMenu key={header.id}>
<ContextMenuTrigger asChild>
<TableHead className="cursor-context-menu">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => header.column.toggleVisibility()}
>
{header.column.getIsVisible() ? "Hide Column" : "Show Column"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleShowAllColumns}>
Show All Columns
</ContextMenuItem>
<ContextMenuItem onClick={handleHideAllColumns}>
Hide All Columns
</ContextMenuItem>
<ContextMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-muted-foreground">
Column Visibility
</div>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<ContextMenuItem
key={column.id}
onClick={() => column.toggleVisibility()}
className="flex items-center justify-between"
>
<span className="capitalize">{column.id}</span>
<div className={`w-2 h-2 rounded-full ${column.getIsVisible() ? 'bg-green-500' : 'bg-gray-300'}`} />
</ContextMenuItem>
)
})}
</ContextMenuContent>
</ContextMenu>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</TableRoot>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}
)
DataTable.displayName = "DataTable"
export default DataTable
+14 -2
View File
@@ -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: <MapPin className="h-4 w-4" />,
}
},
// Empty Videos
{
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" />,
},
];
+6 -1
View File
@@ -38,6 +38,9 @@ export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asse
export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview";
export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id;
export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap";
export const LIST_EMPTY_VIDEOS_PATH = BASE_API_ENDPOINT + "/assets/empty-videos";
export const ASSET_SHARE_THUMBNAIL_PATH = (
{ id, size, token, isPeople }: { id: string, size: string, token: string, isPeople: boolean }
@@ -70,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";
+21 -2
View File
@@ -1,10 +1,12 @@
import {
ASSET_GEO_HEATMAP_PATH,
LIST_EMPTY_VIDEOS_PATH,
FIND_ASSETS,
LIST_MISSING_LOCATION_ALBUMS_PATH,
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";
@@ -50,6 +52,7 @@ export interface IUpdateAssetsParams {
latitude?: number;
longitude?: number;
dateTimeOriginal?: string;
duplicateId?: string | null;
}
export const updateAssets = async (params: IUpdateAssetsParams) => {
@@ -70,5 +73,21 @@ export const getAssetGeoHeatmap = async (filters: IHeatMapParams) => {
}
export const deleteAssets = async (ids: string[]) => {
return API.delete(UPDATE_ASSETS_PATH, { ids });
}
return API.delete(UPDATE_ASSETS_PATH, { ids, force: true });
}
export interface IEmptyVideosParams {
limit: number;
page: number;
maxDuration: number;
sortBy?: string;
sortOrder?: string;
}
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);
}
+2
View File
@@ -0,0 +1,2 @@
export { useAlbumSelection } from './useAlbumSelection'
export { useGridDimensions } from './useGridDimensions'
+68
View File
@@ -0,0 +1,68 @@
import { useState, useCallback } from 'react'
import { IAlbum } from '@/types/album'
/**
* Custom hook for album selection with shift-click support
* @param albums - Array of albums to manage selection for
* @returns Selection state and methods
*/
export const useAlbumSelection = (albums: IAlbum[]) => {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null)
const selectAlbum = useCallback((albumId: string, isShiftClick: boolean = false) => {
if (isShiftClick && lastSelectedId && lastSelectedId !== albumId) {
// Handle shift-click range selection
const currentIndex = albums.findIndex(album => album.id === albumId)
const lastIndex = albums.findIndex(album => album.id === lastSelectedId)
if (currentIndex !== -1 && lastIndex !== -1) {
const startIndex = Math.min(currentIndex, lastIndex)
const endIndex = Math.max(currentIndex, lastIndex)
const newSelectedIds = new Set(selectedIds)
for (let i = startIndex; i <= endIndex; i++) {
newSelectedIds.add(albums[i].id)
}
setSelectedIds(newSelectedIds)
}
} else {
// Toggle single selection
const newSelectedIds = new Set(selectedIds)
if (newSelectedIds.has(albumId)) {
newSelectedIds.delete(albumId)
} else {
newSelectedIds.add(albumId)
}
setSelectedIds(newSelectedIds)
}
setLastSelectedId(albumId)
}, [albums, selectedIds, lastSelectedId])
const selectAll = useCallback(() => {
setSelectedIds(new Set(albums.map(album => album.id)))
}, [albums])
const clearSelection = useCallback(() => {
setSelectedIds(new Set())
setLastSelectedId(null)
}, [])
const isSelected = useCallback((albumId: string) => selectedIds.has(albumId), [selectedIds])
const selectedCount = selectedIds.size
const isAllSelected = albums.length > 0 && selectedCount === albums.length
const isPartiallySelected = selectedCount > 0 && selectedCount < albums.length
return {
selectedIds: Array.from(selectedIds),
selectedCount,
isSelected,
selectAlbum,
selectAll,
clearSelection,
isAllSelected,
isPartiallySelected
}
}
+82
View File
@@ -0,0 +1,82 @@
import { useState, useEffect, useCallback } from 'react';
interface UseGridDimensionsProps {
containerWidth: number;
containerHeight: number;
itemSize: number;
gap?: number;
minColumns?: number;
maxColumns?: number;
}
interface GridDimensions {
columnCount: number;
rowCount: number;
itemSize: number;
gap: number;
width: number;
height: number;
}
export const useGridDimensions = ({
containerWidth,
containerHeight,
itemSize,
gap = 8,
minColumns = 2,
maxColumns = 10
}: UseGridDimensionsProps): GridDimensions => {
const [dimensions, setDimensions] = useState<GridDimensions>({
columnCount: minColumns,
rowCount: 0,
itemSize,
gap,
width: containerWidth,
height: containerHeight
});
const calculateDimensions = useCallback(() => {
if (containerWidth <= 0) return;
// Calculate optimal column count based on container width
const availableWidth = containerWidth - gap; // Account for gap
const optimalColumnCount = Math.floor(availableWidth / (itemSize + gap));
// Clamp column count between min and max
const columnCount = Math.max(minColumns, Math.min(maxColumns, optimalColumnCount));
// Calculate actual item size to fit the container
const actualItemSize = (availableWidth - (columnCount - 1) * gap) / columnCount;
// Calculate total width needed
const totalWidth = columnCount * actualItemSize + (columnCount - 1) * gap;
// Calculate height (this will be updated when we know the total number of items)
const totalHeight = containerHeight;
setDimensions({
columnCount,
rowCount: 0, // Will be calculated when we have the data
itemSize: actualItemSize,
gap,
width: totalWidth,
height: totalHeight
});
}, [containerWidth, containerHeight, itemSize, gap, minColumns, maxColumns]);
useEffect(() => {
calculateDimensions();
}, [calculateDimensions]);
const updateRowCount = useCallback((totalItems: number) => {
setDimensions(prev => ({
...prev,
rowCount: Math.ceil(totalItems / prev.columnCount)
}));
}, []);
return {
...dimensions,
updateRowCount
};
};
+1 -1
View File
@@ -145,7 +145,7 @@ export default function AlbumListPage() {
}
return (
<PageLayout className="!p-0 !mb-0 relative">
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent={albumInfo?.albumName || "Loading..."}
rightComponent={
+167 -50
View File
@@ -4,16 +4,20 @@ import Loader from '@/components/ui/loader'
import { useConfig } from '@/contexts/ConfigContext'
import { deleteAlbums, listAlbums } from '@/handlers/api/album.handler'
import { IAlbum } from '@/types/album'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail'
import { Button } from '@/components/ui/button'
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRouter } from 'next/router'
import { Share, SortAsc, SortDesc, Trash } from 'lucide-react'
import { Share, SortAsc, SortDesc, Trash, Grid3X3, Table as TableIcon } from 'lucide-react'
import { Input } from '@/components/ui/input'
import AlbumShareDialog, { IAlbumShareDialogRef } from '@/components/albums/share/AlbumShareDialog'
import { AlertDialog } from '@/components/ui/alert-dialog'
import toast from 'react-hot-toast'
import { useAlbumSelection } from '@/hooks'
import FloatingBar from '@/components/shared/FloatingBar'
import DataTable, { DataTableRef } from '@/components/ui/data-table'
import { albumColumns } from '@/components/albums/AlbumTableColumns'
const SORT_BY_OPTIONS = [
{ value: 'lastPhotoDate', label: 'Last Photo Date' },
@@ -25,22 +29,46 @@ const SORT_BY_OPTIONS = [
{ value: 'albumSize', label: 'Album Size' },
{ value: 'faceCount', label: 'Number of People' },
]
export default function AlbumListPage() {
const router = useRouter()
const [search, setSearch] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const { query, pathname } = router
const { sortBy, sortOrder } = query as { sortBy: string, sortOrder: string }
const [albums, setAlbums] = useState<IAlbum[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [selectedAlbumsIds, setSelectedAlbumsIds] = useState<string[]>([])
const albumShareDialogRef = useRef<IAlbumShareDialogRef>(null);
const [deleting, setDeleting] = useState(false)
const dataTableRef = useRef<DataTableRef<IAlbum>>(null)
// Separate selection state for table view
const [tableSelectedIds, setTableSelectedIds] = useState<string[]>([])
const selectedSortBy = useMemo(() => SORT_BY_OPTIONS.find((option) => option.value === sortBy), [sortBy])
const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search])
const {
selectedIds,
selectedCount,
isSelected,
selectAlbum,
selectAll,
clearSelection,
isAllSelected,
isPartiallySelected
} = useAlbumSelection(searchedAlbums)
const selectedAlbums = useMemo(() => albums.filter((album) => selectedIds.includes(album.id)), [albums, selectedIds])
// Use appropriate selection state based on view mode
const currentSelectedIds = viewMode === 'table' ? tableSelectedIds : selectedIds
const currentSelectedCount = currentSelectedIds.length
const currentSelectedAlbums = useMemo(() => albums.filter((album) => currentSelectedIds.includes(album.id)), [albums, currentSelectedIds])
const fetchAlbums = async () => {
setLoading(true)
listAlbums({
@@ -56,27 +84,20 @@ export default function AlbumListPage() {
})
}
const selectedAlbums = useMemo(() => albums.filter((album) => selectedAlbumsIds.includes(album.id)), [albums, selectedAlbumsIds])
useEffect(() => {
fetchAlbums()
}, [sortBy, sortOrder])
const handleSelect = (checked: boolean, albumId: string) => {
if (checked) {
setSelectedAlbumsIds([...selectedAlbumsIds, albumId])
} else {
setSelectedAlbumsIds(selectedAlbumsIds.filter((id) => id !== albumId))
}
}
const handleDeleteAlbums = async () => {
setDeleting(true)
return deleteAlbums(selectedAlbumsIds).then(() => {
setSelectedAlbumsIds([])
setAlbums(albums.filter((album) => !selectedAlbumsIds.includes(album.id)))
toast.success(`Deleted ${selectedAlbumsIds.length} albums`)
return deleteAlbums(currentSelectedIds).then(() => {
if (viewMode === 'table') {
setTableSelectedIds([])
} else {
clearSelection()
}
setAlbums(albums.filter((album) => !currentSelectedIds.includes(album.id)))
toast.success(`Deleted ${currentSelectedCount} albums`)
}).catch((error) => {
toast.error(error.message)
}).finally(() => {
@@ -84,6 +105,29 @@ export default function AlbumListPage() {
})
}
const handleTableSelectionChange = useCallback((selectedRowIds: string[]) => {
setTableSelectedIds(selectedRowIds)
}, [])
const handleSelectAll = () => {
if (viewMode === 'table') {
setTableSelectedIds(searchedAlbums.map(album => album.id))
} else {
selectAll()
}
}
const handleClearSelection = () => {
if (viewMode === 'table') {
setTableSelectedIds([])
} else {
clearSelection()
}
}
const isAllSelectedCurrent = searchedAlbums.length > 0 && currentSelectedCount === searchedAlbums.length
const isPartiallySelectedCurrent = currentSelectedCount > 0 && currentSelectedCount < searchedAlbums.length
const renderContent = () => {
if (loading) {
return <Loader />
@@ -91,54 +135,73 @@ export default function AlbumListPage() {
else if (errorMessage) {
return <div>{errorMessage}</div>
}
if (viewMode === 'table') {
return (
<div className="p-4">
<DataTable
columns={albumColumns}
data={searchedAlbums}
onRowSelectionChange={handleTableSelectionChange}
getRowId={(row) => row.id}
searchValue={search}
onSearchChange={setSearch}
ref={dataTableRef}
/>
</div>
)
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 p-4">
{searchedAlbums.map((album) => (
<AlbumThumbnail
album={album}
key={album.id}
selected={selectedAlbumsIds.includes(album.id)}
onSelect={(checked) => handleSelect(checked, album.id)}
selected={isSelected(album.id)}
onSelect={selectAlbum}
/>
))}
</div>
)
}
return (
<PageLayout className="!p-0 !mb-0">
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent="Manage Albums"
leftComponent={
<div className="flex items-center gap-2">
<span>Manage Albums</span>
{currentSelectedCount > 0 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
({currentSelectedCount} selected)
</span>
)}
</div>
}
rightComponent={
<div className="flex items-center gap-2">
{!!selectedAlbumsIds.length && (
<>
<Button
variant={"secondary"}
size={"sm"}
className="flex items-center gap-2"
onClick={() => {
albumShareDialogRef.current?.open(selectedAlbums)
}}>
<Share size={16} /> Share {selectedAlbumsIds.length} albums
</Button>
<AlertDialog
title='Delete Albums'
description='Are you sure you want to delete these albums?'
onConfirm={handleDeleteAlbums}
>
<Button
variant={"destructive"}
size={"sm"}
className="flex items-center gap-2"
disabled={deleting}
>
<Trash size={16} /> {deleting ? "Deleting..." : `Delete ${selectedAlbumsIds.length} albums`}
</Button>
</AlertDialog>
</>
)}
<Input type="text" placeholder="Search" className="w-48" onChange={(e) => setSearch(e.target.value)} />
<div className="flex items-center border rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="rounded-r-none"
>
<Grid3X3 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'table' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('table')}
className="rounded-l-none"
>
<TableIcon className="h-4 w-4" />
</Button>
</div>
<Select
defaultValue={selectedSortBy?.value}
onValueChange={(value) => router.push({
@@ -171,6 +234,60 @@ export default function AlbumListPage() {
}
/>
{renderContent()}
{currentSelectedCount > 0 && (
<FloatingBar>
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-sm text-muted-foreground">
{currentSelectedCount} Selected
</p>
<div className="flex items-center gap-2">
{isAllSelectedCurrent ? (
<Button
variant="outline"
size="sm"
onClick={handleClearSelection}
className="text-xs"
>
Clear Selection
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
className="text-xs"
>
Select All
</Button>
)}
<Button
variant="secondary"
size="sm"
className="flex items-center gap-2"
onClick={() => {
albumShareDialogRef.current?.open(currentSelectedAlbums)
}}>
<Share size={16} /> Share
</Button>
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
<AlertDialog
title='Delete Albums'
description='Are you sure you want to delete these albums?'
onConfirm={handleDeleteAlbums}
>
<Button
variant="destructive"
size="sm"
className="flex items-center gap-2"
disabled={deleting}
>
<Trash size={16} /> {deleting ? "Deleting..." : "Delete"}
</Button>
</AlertDialog>
</div>
</div>
</FloatingBar>
)}
<AlbumShareDialog
ref={albumShareDialogRef}
/>
+5 -4
View File
@@ -112,7 +112,7 @@ export default function PotentialAlbums() {
}
return (
<PageLayout className="!p-0 !mb-0 relative">
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent="Potential Albums"
rightComponent={(
@@ -150,6 +150,7 @@ export default function PotentialAlbums() {
{contextState.selectedIds.length} Selected
</p>
<div className="flex items-center gap-2">
{contextState.selectedIds.length === contextState.assets.length ? (
<Button
variant={"outline"}
size={"sm"}
@@ -161,8 +162,8 @@ export default function PotentialAlbums() {
>
Unselect all
</Button>
<Button
) : (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
@@ -170,10 +171,10 @@ export default function PotentialAlbums() {
selectedIds: contextState.assets.map((a) => a.id),
})
}
>
Select all
</Button>
)}
<AlbumSelectorDialog onSelected={handleSelect} onSubmit={handleCreate} />
<AssetOffsetDialog assets={selectedAssets} onComplete={handleOffsetComplete} />
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
+109
View File
@@ -0,0 +1,109 @@
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiRequest, NextApiResponse } from "next";
import { and, eq, isNotNull, sql, desc, asc } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { exif } from "@/schema";
import { isFlipped } from "@/helpers/asset.helper";
import { ASSET_VIDEO_PATH } from "@/config/routes";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const currentUser = await getCurrentUser(req);
const {
limit = 10,
page = 1,
maxDuration = 2,
sortBy = 'localDateTime',
sortOrder = 'desc',
} = req.query as unknown as {
limit: string | number;
page: string | number;
maxDuration: string | number;
sortBy: string;
sortOrder: string;
};
// Convert to numbers
const limitNum = typeof limit === 'string' ? parseInt(limit, 10) : limit;
const pageNum = typeof page === 'string' ? parseInt(page, 10) : page;
const maxDurationNum = typeof maxDuration === 'string' ? parseFloat(maxDuration) : maxDuration;
if (!currentUser) {
return res.status(401).json({ message: "Unauthorized" });
}
const durationExpr = sql<number>`
(
CAST(SUBSTRING(${assets.duration}, 1, 2) AS DECIMAL) * 3600 +
CAST(SUBSTRING(${assets.duration}, 4, 2) AS DECIMAL) * 60 +
CAST(SUBSTRING(${assets.duration}, 7, 2) AS DECIMAL) +
CAST(SUBSTRING(${assets.duration}, 10, 3) AS DECIMAL) / 1000
)
`;
const dbAssets = await db
.selectDistinctOn([assets.id], {
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
duration: assets.duration,
encodedVideoPath: assets.encodedVideoPath,
originalFileName: assets.originalFileName,
sidecarPath: assets.sidecarPath,
deletedAt: assets.deletedAt,
localDateTime: assets.localDateTime,
exifImageWidth: exif.exifImageWidth,
exifImageHeight: exif.exifImageHeight,
ownerId: assets.ownerId,
dateTimeOriginal: exif.dateTimeOriginal,
orientation: exif.orientation,
durationSeconds: durationExpr.as('duration_seconds'),
})
.from(assets)
.leftJoin(exif, eq(assets.id, exif.assetId))
.where(
and(
eq(assets.ownerId, currentUser.id),
eq(assets.type, "VIDEO"),
eq(assets.visibility, "timeline"),
eq(assets.status, "active"),
isNotNull(assets.duration),
maxDurationNum > 0 ? sql`${durationExpr} < ${maxDurationNum}` : undefined
)
)
.orderBy(
assets.id,
sortBy === "duration"
? (sortOrder === "desc" ? desc(durationExpr) : asc(durationExpr))
: (sortOrder === "desc"
? desc(assets.localDateTime)
: asc(assets.localDateTime))
)
.limit(limitNum)
.offset((pageNum - 1) * limitNum);
const cleanedAssets = dbAssets.map((asset) => {
return {
...asset,
exifImageHeight: isFlipped(asset?.orientation)
? asset?.exifImageWidth
: asset?.exifImageHeight,
exifImageWidth: isFlipped(asset?.orientation)
? asset?.exifImageHeight
: asset?.exifImageWidth,
orientation: asset?.orientation,
downloadUrl: asset?.id ? ASSET_VIDEO_PATH(asset.id) : null,
};
});
return res.status(200).json(cleanedAssets);
}
+34 -9
View File
@@ -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,
},
}
+5 -5
View File
@@ -1,7 +1,7 @@
import { db } from "@/config/db";
import { ENV } from "@/config/environment";
import { desc, gte, lte } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { desc, gte, lte, sql } from "drizzle-orm";
import { eq, inArray, or, isNull, count } from "drizzle-orm";
import { assetFaces, assets, exif } from "@/schema";
import { and } from "drizzle-orm";
import { JsonWebTokenError, sign, verify } from "jsonwebtoken";
@@ -76,8 +76,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
orientation: exif.orientation,
}).from(assets)
.innerJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
.innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id))
.leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
.leftJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id))
.innerJoin(exif, eq(exif.assetId, assets.id))
.where(and(
filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined,
@@ -88,7 +88,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
eq(assets.visibility, "timeline"),
eq(assets.isOffline, false),
))
.orderBy(desc(assets.createdAt));
.orderBy(desc(assets.localDateTime));
const cleanedAssets = dbAssets.map((asset) => {
return {
+328
View File
@@ -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>
)
}
+282
View File
@@ -0,0 +1,282 @@
import PageLayout from '@/components/layouts/PageLayout'
import Header from '@/components/shared/Header'
import Loader from '@/components/ui/loader'
import { useConfig } from '@/contexts/ConfigContext'
import { listEmptyVideos, deleteAssets } from '@/handlers/api/asset.handler'
import { IAsset } from '@/types/asset'
import React, { useEffect, useState, useMemo, useRef } from 'react'
import { useRouter } from 'next/router'
import { Camera, Trash2 } from 'lucide-react'
import { humanizeNumber } from '@/helpers/string.helper'
import PhotoSelectionContext, { IPhotoSelectionContext } from '@/contexts/PhotoSelectionContext'
import FloatingBar from '@/components/shared/FloatingBar'
import { Button } from '@/components/ui/button'
import { AlertDialog } from '@/components/ui/alert-dialog'
import AssetGrid from '@/components/shared/AssetGrid'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
export default function EmptyVideosPage() {
const router = useRouter()
const [assets, setAssets] = useState<IAsset[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
// Initialize filters from URL params or defaults
const getInitialFilters = () => {
const { maxDuration, sortBy, sortOrder, page, limit } = router.query
return {
limit: typeof limit === 'string' ? parseInt(limit, 10) : 100,
page: typeof page === 'string' ? parseInt(page, 10) : 1,
maxDuration: typeof maxDuration === 'string' ? parseFloat(maxDuration) : 2,
sortBy: typeof sortBy === 'string' ? sortBy : 'localDateTime',
sortOrder: typeof sortOrder === 'string' ? sortOrder : 'desc'
}
}
const [filters, setFilters] = useState(getInitialFilters)
// Update URL with current filters
const updateURL = (newFilters: typeof filters) => {
const params = new URLSearchParams()
if (newFilters.maxDuration > 0) params.set('maxDuration', newFilters.maxDuration.toString())
if (newFilters.sortBy !== 'localDateTime') params.set('sortBy', newFilters.sortBy)
if (newFilters.sortOrder !== 'desc') params.set('sortOrder', newFilters.sortOrder)
if (newFilters.page > 1) params.set('page', newFilters.page.toString())
if (newFilters.limit !== 100) params.set('limit', newFilters.limit.toString())
const newURL = params.toString() ? `?${params.toString()}` : ''
router.push(`${router.pathname}${newURL}`, undefined, { shallow: true })
}
// PhotoSelection context state
const [contextState, setContextState] = useState<IPhotoSelectionContext>({
selectedIds: [],
assets: [],
config: {
albumId: '',
sort: "fileOriginalDate",
sortOrder: "asc"
},
updateContext: (newConfig: Partial<IPhotoSelectionContext>) => {
setContextState(prevState => ({
...prevState,
...newConfig,
config: newConfig.config ? { ...prevState.config, ...newConfig.config } : prevState.config
}));
}
});
const selectedAssets = useMemo(() =>
contextState.assets.filter((a) => contextState.selectedIds.includes(a.id)),
[contextState.assets, contextState.selectedIds]
);
const fetchEmptyVideos = async () => {
setLoading(true)
listEmptyVideos(filters)
.then((fetchedAssets) => {
setAssets(fetchedAssets)
contextState.updateContext({
assets: fetchedAssets
})
})
.catch((error) => {
setErrorMessage(error.message)
})
.finally(() => {
setLoading(false)
})
}
useEffect(() => {
fetchEmptyVideos()
}, [filters])
const handleSelectionChange = (selectedIds: string[]) => {
contextState.updateContext({
selectedIds: selectedIds
})
}
const handleDelete = () => {
return deleteAssets(contextState.selectedIds).then(() => {
const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id));
setAssets(newAssets)
contextState.updateContext({
selectedIds: [],
assets: newAssets,
});
})
}
const [durationInputValue, setDurationInputValue] = useState(filters.maxDuration === 0 ? '' : filters.maxDuration.toString())
// Update filters when URL changes
useEffect(() => {
if (router.isReady) {
const newFilters = getInitialFilters()
setFilters(newFilters)
setDurationInputValue(newFilters.maxDuration === 0 ? '' : newFilters.maxDuration.toString())
}
}, [router.isReady, router.query])
const handleMaxDurationChange = (value: string) => {
setDurationInputValue(value)
}
const handleMaxDurationBlur = () => {
if (durationInputValue === '') {
const newFilters = { ...filters, maxDuration: 0, page: 1 }
setFilters(newFilters)
updateURL(newFilters)
return
}
const numValue = parseFloat(durationInputValue)
if (isNaN(numValue) || numValue < 0.1) {
// Reset to previous valid value
setDurationInputValue(filters.maxDuration === 0 ? '' : filters.maxDuration.toString())
return
}
const newFilters = { ...filters, maxDuration: numValue, page: 1 }
setFilters(newFilters)
updateURL(newFilters)
}
const handleSortChange = (sortBy: string) => {
const newFilters = { ...filters, sortBy, page: 1 }
setFilters(newFilters)
updateURL(newFilters)
}
const handleSortOrderChange = (sortOrder: string) => {
const newFilters = { ...filters, sortOrder, page: 1 }
setFilters(newFilters)
updateURL(newFilters)
}
const renderContent = () => {
if (loading) {
return <Loader />
}
else if (errorMessage) {
return <div className="text-red-500 p-4">{errorMessage}</div>
}
return (
<div className="p-4">
{assets.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No empty videos found with the current filters.
</div>
) : (
<AssetGrid
assets={assets}
selectable={true}
onSelectionChange={handleSelectionChange}
/>
)}
</div>
)
}
return (
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent="Empty Videos"
rightComponent={
!loading && (
<div className="flex items-center gap-4">
{/* Duration Filter */}
<div className="flex items-center gap-2">
<Label htmlFor="maxDuration" className="text-sm font-medium whitespace-nowrap">
Max Duration:
</Label>
<Input
id="maxDuration"
type="number"
min="0.1"
max="60"
step="0.1"
value={durationInputValue}
onChange={(e) => handleMaxDurationChange(e.target.value)}
onBlur={handleMaxDurationBlur}
className="w-20 h-8"
/>
<span className="text-sm text-gray-500">s</span>
</div>
{/* Asset Count */}
<div className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-500">
{humanizeNumber(assets.length)}
{' '}
<Camera className="w-4 h-4" />
</div>
</div>
)
}
/>
<PhotoSelectionContext.Provider
value={{
...contextState,
updateContext: contextState.updateContext,
}}
>
{renderContent()}
{selectedAssets.length > 0 &&
<FloatingBar>
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-sm text-muted-foreground">
{contextState.selectedIds.length} Selected
</p>
<div className="flex items-center gap-2">
{contextState.selectedIds.length === contextState.assets.length ? (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
contextState.updateContext({
selectedIds: [],
})
}
>
Unselect all
</Button>
) : (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
contextState.updateContext({
selectedIds: contextState.assets.map((a) => a.id),
})
}
>
Select all
</Button>
)}
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
<AlertDialog
title="Delete the selected videos?"
description="This action will delete the selected videos and cannot be undone."
onConfirm={handleDelete}
disabled={contextState.selectedIds.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={contextState.selectedIds.length === 0}>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialog>
</div>
</div>
</FloatingBar>
}
</PhotoSelectionContext.Provider>
</PageLayout>
)
}
+1 -1
View File
@@ -94,7 +94,7 @@ export default function MissingLocations() {
}
return (
<PageLayout className="!p-0 !mb-0 relative">
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent="Missing Location"
rightComponent={
+1 -1
View File
@@ -270,7 +270,7 @@ export default function FindPage() {
}
return (
<PageLayout>
<PageLayout className="pb-20">
<Header leftComponent="Find" />
{geminiEnabled ? (
<>
+61
View File
@@ -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;
}