Merge pull request #173 from varun-raj/feat-august-release
Feat: August release
This commit is contained in:
39 files changed
+3208
-433
No files matched your search
+9
-1
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" />,
|
||||
},
|
||||
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useAlbumSelection } from './useAlbumSelection'
|
||||
export { useGridDimensions } from './useGridDimensions'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function FindPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageLayout className="pb-20">
|
||||
<Header leftComponent="Find" />
|
||||
{geminiEnabled ? (
|
||||
<>
|
||||
|
||||
Vendored
+61
@@ -24,3 +24,64 @@ export interface IAssetThumbhash {
|
||||
type: string;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
|
||||
export interface IDuplicateAssetRecord {
|
||||
duplicateId: string;
|
||||
assets: IDuplicateAsset[];
|
||||
}
|
||||
|
||||
export interface IDuplicateAsset {
|
||||
id: string;
|
||||
deviceAssetId: string;
|
||||
ownerId: string;
|
||||
deviceId: string;
|
||||
libraryId: null;
|
||||
type: string;
|
||||
originalPath: string;
|
||||
originalFileName: string;
|
||||
originalMimeType: string;
|
||||
thumbhash: string;
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
localDateTime: Date;
|
||||
updatedAt: Date;
|
||||
isFavorite: boolean;
|
||||
isArchived: boolean;
|
||||
isTrashed: boolean;
|
||||
visibility: string;
|
||||
duration: string;
|
||||
exifInfo: IDuplicateAssetExifInfo;
|
||||
livePhotoVideoId: null;
|
||||
people: any[];
|
||||
checksum: string;
|
||||
isOffline: boolean;
|
||||
hasMetadata: boolean;
|
||||
duplicateId: string;
|
||||
resized: boolean;
|
||||
}
|
||||
|
||||
export interface IDuplicateAssetExifInfo {
|
||||
make: string;
|
||||
model: string;
|
||||
exifImageWidth: number;
|
||||
exifImageHeight: number;
|
||||
fileSizeInByte: number;
|
||||
orientation: string;
|
||||
dateTimeOriginal: Date;
|
||||
modifyDate: Date;
|
||||
timeZone: string;
|
||||
lensModel: string;
|
||||
fNumber: number;
|
||||
focalLength: number;
|
||||
iso: number;
|
||||
exposureTime: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
description: string;
|
||||
projectionType: null;
|
||||
rating: null;
|
||||
}
|
||||
Reference in new issue
Block a user