Recent Searches
@@ -50,7 +50,7 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }:
"hover:bg-zinc-200 dark:hover:bg-zinc-800 flex justify-between items-center px-3 py-2 rounded-lg cursor-pointer border",
{
"bg-zinc-200 dark:bg-zinc-800 border-primary":
- selectedPlace &&
+ selectedPlace &&
selectedPlace.name === search.name &&
selectedPlace.latitude === search.latitude &&
selectedPlace.longitude === search.longitude,
@@ -60,7 +60,7 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }:
-
{search.name}
+
{search.name}
@@ -76,4 +76,4 @@ export default function RecentSearches({ searchType, onSelect, selectedPlace }:
);
-}
\ No newline at end of file
+}
diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx
index 374550d..3c22822 100644
--- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx
+++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx
@@ -54,7 +54,7 @@ export default function TagMissingLocationDialog({
Tagging a location will add the location to the selected {selectedIds.length} asset(s).
-
+
Open Street Map
Immich Geo
@@ -71,16 +71,17 @@ export default function TagMissingLocationDialog({
-
+
{/* Recent Searches */}
-
{
setMapPosition(place);
}}
selectedPlace={mapPosition.name ? mapPosition : null}
+ className="w-[500px]" // Matching the width of the map
/>
-
+
void;
}
-export default function AssetGrid({ assets, isInternal = true, selectable = false, onSelectionChange }: AssetGridProps) {
+interface AssetGridRef {
+ getSelectedIds: () => string[];
+ selectAll: () => void;
+ unselectAll: () => void;
+}
+
+const AssetGrid = forwardRef(({ assets, isInternal = true, selectable = false, onSelectionChange }, ref) => {
const [index, setIndex] = useState(-1);
- const [selectedIds, setSelectedIds] = useState([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
+
+ // Use context for selection state
+ const { selectedIds, updateContext } = usePhotoSelectionContext();
+
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => selectedIds,
+ selectAll: () => {
+ const allIds = assets.map((asset) => asset.id);
+ updateContext({ selectedIds: allIds });
+ onSelectionChange?.(allIds);
+ },
+ unselectAll: () => {
+ updateContext({ selectedIds: [] });
+ onSelectionChange?.([]);
+ },
+ }), [assets, selectedIds, updateContext]);
const handleClick = (index: number, asset: IAsset, event: React.MouseEvent) => {
@@ -35,8 +57,9 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
event.stopPropagation();
const isPresent = selectedIds.includes(asset.id);
if (isPresent) {
- setSelectedIds(selectedIds.filter((id) => id !== asset.id));
- onSelectionChange?.(selectedIds.filter((id) => id !== asset.id));
+ const newSelectedIds = selectedIds.filter((id) => id !== asset.id);
+ updateContext({ selectedIds: newSelectedIds });
+ onSelectionChange?.(newSelectedIds);
} else {
const clickedIndex = images.findIndex((image) => {
return image.id === asset.id;
@@ -44,14 +67,15 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
if (event.shiftKey) {
const startIndex = Math.min(clickedIndex, lastSelectedIndex);
const endIndex = Math.max(clickedIndex, lastSelectedIndex);
- const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id);
- const allSelectedIds = [...selectedIds, ...newSelectedIds];
+ const rangeSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id);
+ const allSelectedIds = [...selectedIds, ...rangeSelectedIds];
const uniqueSelectedIds = [...new Set(allSelectedIds)];
- setSelectedIds(uniqueSelectedIds);
+ updateContext({ selectedIds: uniqueSelectedIds });
onSelectionChange?.(uniqueSelectedIds);
} else {
- setSelectedIds([...selectedIds, asset.id]);
- onSelectionChange?.([...selectedIds, asset.id]);
+ const newSelectedIds = [...selectedIds, asset.id];
+ updateContext({ selectedIds: newSelectedIds });
+ onSelectionChange?.(newSelectedIds);
}
setLastSelectedIndex(clickedIndex);
}
@@ -93,7 +117,7 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
const handleEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") {
- setSelectedIds([]);
+ updateContext({ selectedIds: [] });
onSelectionChange?.([]);
}
};
@@ -122,4 +146,6 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
/>
);
-}
+})
+AssetGrid.displayName = "AssetGrid";
+export default AssetGrid;
diff --git a/src/components/shared/AssetGridExample.tsx b/src/components/shared/AssetGridExample.tsx
new file mode 100644
index 0000000..e1913c7
--- /dev/null
+++ b/src/components/shared/AssetGridExample.tsx
@@ -0,0 +1,87 @@
+import React, { useRef } from 'react';
+import { IAsset } from '@/types/asset';
+import VirtualizedAssetGrid from './VirtualizedAssetGrid';
+import ResponsiveVirtualizedAssetGrid from './ResponsiveVirtualizedAssetGrid';
+import { Button } from '@/components/ui/button';
+
+interface AssetGridExampleProps {
+ assets: IAsset[];
+ onSelectionChange?: (ids: string[]) => void;
+}
+
+export default function AssetGridExample({ assets, onSelectionChange }: AssetGridExampleProps) {
+ const gridRef = useRef
(null);
+
+ const handleSelectAll = () => {
+ gridRef.current?.selectAll();
+ };
+
+ const handleUnselectAll = () => {
+ gridRef.current?.unselectAll();
+ };
+
+ return (
+
+
+
+ Select All
+
+
+ Unselect All
+
+
+
+ {/* Example 1: Basic Virtualized Grid with fixed dimensions */}
+
+
Fixed Size Virtualized Grid
+
+
+
+
+
+ {/* Example 2: Responsive Virtualized Grid */}
+
+
Responsive Virtualized Grid
+
+
+
+
+
+ {/* Example 3: Responsive Grid with different target sizes */}
+
+
Small Items Virtualized Grid
+
+
+
+
+
+ );
+}
diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx
index 701d509..072eb13 100644
--- a/src/components/shared/FloatingBar.tsx
+++ b/src/components/shared/FloatingBar.tsx
@@ -1,12 +1,14 @@
+import clsx from 'clsx';
import React from 'react'
interface FloatingBarProps {
children: React.ReactNode;
+ className?: string;
}
-export default function FloatingBar({ children }: FloatingBarProps) {
+export default function FloatingBar({ children, className }: FloatingBarProps) {
return (
-
+
{children}
)
diff --git a/src/components/shared/ResponsiveVirtualizedAssetGrid.tsx b/src/components/shared/ResponsiveVirtualizedAssetGrid.tsx
new file mode 100644
index 0000000..95d0294
--- /dev/null
+++ b/src/components/shared/ResponsiveVirtualizedAssetGrid.tsx
@@ -0,0 +1,254 @@
+import "yet-another-react-lightbox/styles.css";
+
+import { IAsset } from '@/types/asset';
+import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useCallback, useRef } from 'react'
+import Lightbox from 'yet-another-react-lightbox';
+import Download from "yet-another-react-lightbox/plugins/download";
+import Video from "yet-another-react-lightbox/plugins/video";
+import { usePhotoSelectionContext } from '@/contexts/PhotoSelectionContext';
+import { FixedSizeGrid as Grid } from 'react-window';
+import LazyImage from '../ui/lazy-image';
+import { humanizeDuration } from '@/helpers/string.helper';
+import { PlayIcon } from '@radix-ui/react-icons';
+import { useGridDimensions } from '@/hooks';
+
+interface ResponsiveVirtualizedAssetGridProps {
+ assets: IAsset[];
+ isInternal?: boolean;
+ selectable?: boolean;
+ onSelectionChange?: (ids: string[]) => void;
+ targetItemSize?: number;
+ gap?: number;
+ minColumns?: number;
+ maxColumns?: number;
+ className?: string;
+}
+
+interface ResponsiveVirtualizedAssetGridRef {
+ getSelectedIds: () => string[];
+ selectAll: () => void;
+ unselectAll: () => void;
+}
+
+const ResponsiveVirtualizedAssetGrid = forwardRef
(({
+ assets,
+ isInternal = true,
+ selectable = false,
+ onSelectionChange,
+ targetItemSize = 200,
+ gap = 8,
+ minColumns = 2,
+ maxColumns = 8,
+ className = ""
+}, ref) => {
+ const [index, setIndex] = useState(-1);
+ const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
+ const containerRef = useRef(null);
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
+
+ // Use context for selection state
+ const { selectedIds, updateContext } = usePhotoSelectionContext();
+
+ // Calculate grid dimensions
+ const gridDimensions = useGridDimensions({
+ containerWidth: containerSize.width,
+ containerHeight: containerSize.height,
+ itemSize: targetItemSize,
+ gap,
+ minColumns,
+ maxColumns
+ });
+
+ // Update row count when assets change
+ useEffect(() => {
+ gridDimensions.updateRowCount(assets.length);
+ }, [assets.length, gridDimensions]);
+
+ // Resize observer to handle container size changes
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const { width, height } = entry.contentRect;
+ setContainerSize({ width, height });
+ }
+ });
+
+ resizeObserver.observe(containerRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, []);
+
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => selectedIds,
+ selectAll: () => {
+ const allIds = assets.map((asset) => asset.id);
+ updateContext({ selectedIds: allIds });
+ onSelectionChange?.(allIds);
+ },
+ unselectAll: () => {
+ updateContext({ selectedIds: [] });
+ onSelectionChange?.([]);
+ },
+ }), [assets, selectedIds, updateContext]);
+
+ const slides = useMemo(() => {
+ return assets.map((asset) => ({
+ ...asset,
+ orientation: 1,
+ src: asset.previewUrl as string,
+ type: (asset.type === "VIDEO" ? "video" : "image") as any,
+ sources:
+ asset.type === "VIDEO"
+ ? [
+ {
+ src: asset.downloadUrl as string,
+ type: "video/mp4",
+ },
+ ]
+ : undefined,
+ height: asset.exifImageHeight as number,
+ width: asset.exifImageWidth as number,
+ downloadUrl: asset.downloadUrl as string,
+ }));
+ }, [assets]);
+
+ const handleClick = useCallback((index: number, asset: IAsset, event: React.MouseEvent) => {
+ if (selectedIds.length > 0) {
+ handleSelect(index, asset, event);
+ } else {
+ setIndex(index);
+ }
+ }, [selectedIds]);
+
+ const handleSelect = useCallback((_idx: number, asset: IAsset, event: React.MouseEvent) => {
+ event.stopPropagation();
+ const isPresent = selectedIds.includes(asset.id);
+ if (isPresent) {
+ const newSelectedIds = selectedIds.filter((id) => id !== asset.id);
+ updateContext({ selectedIds: newSelectedIds });
+ onSelectionChange?.(newSelectedIds);
+ } else {
+ const clickedIndex = assets.findIndex((a) => a.id === asset.id);
+ if (event.shiftKey) {
+ const startIndex = Math.min(clickedIndex, lastSelectedIndex);
+ const endIndex = Math.max(clickedIndex, lastSelectedIndex);
+ const rangeSelectedIds = assets.slice(startIndex, endIndex + 1).map((a) => a.id);
+ const allSelectedIds = [...selectedIds, ...rangeSelectedIds];
+ const uniqueSelectedIds = [...new Set(allSelectedIds)];
+ updateContext({ selectedIds: uniqueSelectedIds });
+ onSelectionChange?.(uniqueSelectedIds);
+ } else {
+ const newSelectedIds = [...selectedIds, asset.id];
+ updateContext({ selectedIds: newSelectedIds });
+ onSelectionChange?.(newSelectedIds);
+ }
+ setLastSelectedIndex(clickedIndex);
+ }
+ }, [selectedIds, assets, lastSelectedIndex, updateContext, onSelectionChange]);
+
+ const handleEsc = useCallback((event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ updateContext({ selectedIds: [] });
+ onSelectionChange?.([]);
+ }
+ }, [updateContext, onSelectionChange]);
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleEsc);
+ return () => window.removeEventListener("keydown", handleEsc);
+ }, [handleEsc]);
+
+ // Cell renderer for React Window
+ const Cell = useCallback(({ columnIndex, rowIndex, style }: { columnIndex: number; rowIndex: number; style: React.CSSProperties }) => {
+ const assetIndex = rowIndex * gridDimensions.columnCount + columnIndex;
+ const asset = assets[assetIndex];
+
+ if (!asset) {
+ return
;
+ }
+
+ const isSelected = selectedIds.includes(asset.id);
+
+ return (
+ handleClick(assetIndex, asset, e)}
+ >
+
+
+ {asset.type === "VIDEO" && (
+
+
+ {asset.duration && (
+
+ {humanizeDuration(asset.duration)}
+
+ )}
+
+ )}
+ {selectable && (
+
+ )}
+
+
+ );
+ }, [assets, selectedIds, gridDimensions.columnCount, handleClick, selectable, gap]);
+
+ // Don't render grid until we have container dimensions
+ if (containerSize.width === 0 || gridDimensions.rowCount === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ = 0}
+ index={index}
+ close={() => setIndex(-1)}
+ />
+
+ {Cell}
+
+
+ );
+});
+
+ResponsiveVirtualizedAssetGrid.displayName = "ResponsiveVirtualizedAssetGrid";
+export default ResponsiveVirtualizedAssetGrid;
diff --git a/src/components/shared/VirtualizedAssetGrid.tsx b/src/components/shared/VirtualizedAssetGrid.tsx
new file mode 100644
index 0000000..c038131
--- /dev/null
+++ b/src/components/shared/VirtualizedAssetGrid.tsx
@@ -0,0 +1,205 @@
+import "yet-another-react-lightbox/styles.css";
+
+import { IAsset } from '@/types/asset';
+import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useCallback } from 'react'
+import Lightbox from 'yet-another-react-lightbox';
+import Download from "yet-another-react-lightbox/plugins/download";
+import Video from "yet-another-react-lightbox/plugins/video";
+import { usePhotoSelectionContext } from '@/contexts/PhotoSelectionContext';
+import { FixedSizeGrid as Grid } from 'react-window';
+import LazyImage from '../ui/lazy-image';
+import { humanizeDuration } from '@/helpers/string.helper';
+import { PlayIcon } from '@radix-ui/react-icons';
+
+interface VirtualizedAssetGridProps {
+ assets: IAsset[];
+ isInternal?: boolean;
+ selectable?: boolean;
+ onSelectionChange?: (ids: string[]) => void;
+ columnCount?: number;
+ itemSize?: number;
+ height?: number;
+}
+
+interface VirtualizedAssetGridRef {
+ getSelectedIds: () => string[];
+ selectAll: () => void;
+ unselectAll: () => void;
+}
+
+const VirtualizedAssetGrid = forwardRef(({
+ assets,
+ isInternal = true,
+ selectable = false,
+ onSelectionChange,
+ columnCount = 5,
+ itemSize = 200,
+ height = 600
+}, ref) => {
+ const [index, setIndex] = useState(-1);
+ const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
+
+ // Use context for selection state
+ const { selectedIds, updateContext } = usePhotoSelectionContext();
+
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => selectedIds,
+ selectAll: () => {
+ const allIds = assets.map((asset) => asset.id);
+ updateContext({ selectedIds: allIds });
+ onSelectionChange?.(allIds);
+ },
+ unselectAll: () => {
+ updateContext({ selectedIds: [] });
+ onSelectionChange?.([]);
+ },
+ }), [assets, selectedIds, updateContext]);
+
+ const slides = useMemo(() => {
+ return assets.map((asset) => ({
+ ...asset,
+ orientation: 1,
+ src: asset.previewUrl as string,
+ type: (asset.type === "VIDEO" ? "video" : "image") as any,
+ sources:
+ asset.type === "VIDEO"
+ ? [
+ {
+ src: asset.downloadUrl as string,
+ type: "video/mp4",
+ },
+ ]
+ : undefined,
+ height: asset.exifImageHeight as number,
+ width: asset.exifImageWidth as number,
+ downloadUrl: asset.downloadUrl as string,
+ }));
+ }, [assets]);
+
+ const handleClick = useCallback((index: number, asset: IAsset, event: React.MouseEvent) => {
+ if (selectedIds.length > 0) {
+ handleSelect(index, asset, event);
+ } else {
+ setIndex(index);
+ }
+ }, [selectedIds]);
+
+ const handleSelect = useCallback((_idx: number, asset: IAsset, event: React.MouseEvent) => {
+ event.stopPropagation();
+ const isPresent = selectedIds.includes(asset.id);
+ if (isPresent) {
+ const newSelectedIds = selectedIds.filter((id) => id !== asset.id);
+ updateContext({ selectedIds: newSelectedIds });
+ onSelectionChange?.(newSelectedIds);
+ } else {
+ const clickedIndex = assets.findIndex((a) => a.id === asset.id);
+ if (event.shiftKey) {
+ const startIndex = Math.min(clickedIndex, lastSelectedIndex);
+ const endIndex = Math.max(clickedIndex, lastSelectedIndex);
+ const rangeSelectedIds = assets.slice(startIndex, endIndex + 1).map((a) => a.id);
+ const allSelectedIds = [...selectedIds, ...rangeSelectedIds];
+ const uniqueSelectedIds = [...new Set(allSelectedIds)];
+ updateContext({ selectedIds: uniqueSelectedIds });
+ onSelectionChange?.(uniqueSelectedIds);
+ } else {
+ const newSelectedIds = [...selectedIds, asset.id];
+ updateContext({ selectedIds: newSelectedIds });
+ onSelectionChange?.(newSelectedIds);
+ }
+ setLastSelectedIndex(clickedIndex);
+ }
+ }, [selectedIds, assets, lastSelectedIndex, updateContext, onSelectionChange]);
+
+ const handleEsc = useCallback((event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ updateContext({ selectedIds: [] });
+ onSelectionChange?.([]);
+ }
+ }, [updateContext, onSelectionChange]);
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleEsc);
+ return () => window.removeEventListener("keydown", handleEsc);
+ }, [handleEsc]);
+
+ // Calculate grid dimensions
+ const rowCount = Math.ceil(assets.length / columnCount);
+ const width = columnCount * itemSize;
+
+ // Cell renderer for React Window
+ const Cell = useCallback(({ columnIndex, rowIndex, style }: { columnIndex: number; rowIndex: number; style: React.CSSProperties }) => {
+ const assetIndex = rowIndex * columnCount + columnIndex;
+ const asset = assets[assetIndex];
+
+ if (!asset) {
+ return
;
+ }
+
+ const isSelected = selectedIds.includes(asset.id);
+
+ return (
+ handleClick(assetIndex, asset, e)}
+ >
+
+
+ {asset.type === "VIDEO" && (
+
+
+ {asset.duration && (
+
+ {humanizeDuration(asset.duration)}
+
+ )}
+
+ )}
+ {selectable && (
+
+ )}
+
+
+ );
+ }, [assets, selectedIds, columnCount, handleClick, selectable]);
+
+ return (
+
+ = 0}
+ index={index}
+ close={() => setIndex(-1)}
+ />
+
+ {Cell}
+
+
+ );
+});
+
+VirtualizedAssetGrid.displayName = "VirtualizedAssetGrid";
+export default VirtualizedAssetGrid;
diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..6d0fd00
--- /dev/null
+++ b/src/components/ui/context-menu.tsx
@@ -0,0 +1,197 @@
+import * as React from "react"
+import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
+
+const ContextMenu = ContextMenuPrimitive.Root
+
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger
+
+const ContextMenuGroup = ContextMenuPrimitive.Group
+
+const ContextMenuPortal = ContextMenuPrimitive.Portal
+
+const ContextMenuSub = ContextMenuPrimitive.Sub
+
+const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
+
+const ContextMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
+
+const ContextMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
+
+const ContextMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
+
+const ContextMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
+
+const ContextMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+ContextMenuCheckboxItem.displayName =
+ ContextMenuPrimitive.CheckboxItem.displayName
+
+const ContextMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
+
+const ContextMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
+
+const ContextMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
+
+const ContextMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+ContextMenuShortcut.displayName = "ContextMenuShortcut"
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+}
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx
new file mode 100644
index 0000000..cc3aeb6
--- /dev/null
+++ b/src/components/ui/data-table.tsx
@@ -0,0 +1,262 @@
+"use client"
+
+import * as React from "react"
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ SortingState,
+ useReactTable,
+ VisibilityState,
+} from "@tanstack/react-table"
+import { ChevronDown } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu"
+import {
+ TableRoot,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+
+interface DataTableProps {
+ columns: ColumnDef[]
+ data: TData[]
+ onRowSelectionChange?: (selectedIds: string[]) => void
+ getRowId?: (row: TData) => string
+ searchValue?: string
+ onSearchChange?: (value: string) => void
+}
+
+export interface DataTableRef {
+ selectRows: (rowIds: string[]) => void
+ selectAllRows: () => void
+ deselectAllRows: () => void
+ getSelectedRows: () => string[]
+ clearSelection: () => void
+}
+
+const DataTable = React.forwardRef, DataTableProps>(
+ ({ columns, data, onRowSelectionChange, getRowId, searchValue, onSearchChange }, ref) => {
+ const [sorting, setSorting] = React.useState([])
+ const [columnFilters, setColumnFilters] = React.useState([])
+ const [columnVisibility, setColumnVisibility] = React.useState({})
+ const [rowSelection, setRowSelection] = React.useState>({})
+
+ const table = useReactTable({
+ data,
+ columns,
+ getRowId: getRowId || ((row: any) => row.id),
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ },
+ // Add these options to prevent potential issues
+ enableRowSelection: true,
+ enableMultiRowSelection: true,
+ enableSorting: true,
+ enableFilters: true,
+ enableColumnFilters: true,
+ })
+
+ // Expose methods via ref
+ React.useImperativeHandle(ref, () => ({
+ selectRows: (rowIds: string[]) => {
+ const newSelection: Record = {}
+ rowIds.forEach(id => {
+ newSelection[id] = true
+ })
+ setRowSelection(newSelection)
+ },
+ selectAllRows: () => {
+ const allRowIds = table.getRowModel().rows.map(row => row.id)
+ const newSelection: Record = {}
+ allRowIds.forEach(id => {
+ newSelection[id] = true
+ })
+ setRowSelection(newSelection)
+ },
+ deselectAllRows: () => {
+ setRowSelection({})
+ },
+ getSelectedRows: () => {
+ return Object.keys(rowSelection).filter(id => rowSelection[id])
+ },
+ clearSelection: () => {
+ setRowSelection({})
+ },
+ }), [table])
+
+ // Sync external search with table filter
+ React.useEffect(() => {
+ if (searchValue !== undefined) {
+ table.getColumn("albumName")?.setFilterValue(searchValue)
+ }
+ }, [searchValue, table])
+
+ // Notify parent of selection changes
+ React.useEffect(() => {
+ const selectedRowIds = Object.keys(rowSelection).filter(id => rowSelection[id])
+ onRowSelectionChange?.(selectedRowIds)
+ }, [rowSelection, onRowSelectionChange])
+
+ const handleShowAllColumns = () => {
+ table.toggleAllColumnsVisible(true)
+ }
+
+ const handleHideAllColumns = () => {
+ table.toggleAllColumnsVisible(false)
+ }
+
+ return (
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+
+
+ header.column.toggleVisibility()}
+ >
+ {header.column.getIsVisible() ? "Hide Column" : "Show Column"}
+
+
+
+ Show All Columns
+
+
+ Hide All Columns
+
+
+
+ Column Visibility
+
+ {table
+ .getAllColumns()
+ .filter((column) => column.getCanHide())
+ .map((column) => {
+ return (
+ column.toggleVisibility()}
+ className="flex items-center justify-between"
+ >
+ {column.id}
+
+
+ )
+ })}
+
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Previous
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Next
+
+
+
+
+ )
+ }
+)
+
+DataTable.displayName = "DataTable"
+
+export default DataTable
\ No newline at end of file
diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx
index ebe8d2a..6b95734 100644
--- a/src/config/constants/sidebarNavs.tsx
+++ b/src/config/constants/sidebarNavs.tsx
@@ -1,4 +1,4 @@
-import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User } from "lucide-react";
+import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react";
export const sidebarNavs = [
// {
@@ -41,6 +41,18 @@ export const sidebarNavs = [
title: "Geo Heatmap",
link: "/assets/geo-heatmap",
icon: ,
- }
+ },
+ // Empty Videos
+ {
+ title: "Empty Videos",
+ link: "/assets/empty-videos",
+ icon: ,
+ },
+ // Bulk Duplicate
+ {
+ title: "Bulk Duplicate Finder",
+ link: "/assets/bulk-duplicate-finder",
+ icon: ,
+ },
];
diff --git a/src/config/routes.ts b/src/config/routes.ts
index dabca57..bffdcb0 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -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";
\ No newline at end of file
+export const DOWNLOAD_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/download/archive";
+
+export const LIST_DUPLICATES_PATH = BASE_PROXY_ENDPOINT + "/duplicates";
\ No newline at end of file
diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts
index 13ba4a2..85667a5 100644
--- a/src/handlers/api/asset.handler.ts
+++ b/src/handlers/api/asset.handler.ts
@@ -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 });
-}
\ No newline at end of file
+ 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);
+}
\ No newline at end of file
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..2a4749c
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export { useAlbumSelection } from './useAlbumSelection'
+export { useGridDimensions } from './useGridDimensions'
\ No newline at end of file
diff --git a/src/hooks/useAlbumSelection.ts b/src/hooks/useAlbumSelection.ts
new file mode 100644
index 0000000..3e6189c
--- /dev/null
+++ b/src/hooks/useAlbumSelection.ts
@@ -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>(new Set())
+ const [lastSelectedId, setLastSelectedId] = useState(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
+ }
+}
\ No newline at end of file
diff --git a/src/hooks/useGridDimensions.ts b/src/hooks/useGridDimensions.ts
new file mode 100644
index 0000000..c22aba9
--- /dev/null
+++ b/src/hooks/useGridDimensions.ts
@@ -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({
+ 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
+ };
+};
diff --git a/src/pages/albums/[albumId].tsx b/src/pages/albums/[albumId].tsx
index 2cf9e87..c67c7bf 100644
--- a/src/pages/albums/[albumId].tsx
+++ b/src/pages/albums/[albumId].tsx
@@ -145,7 +145,7 @@ export default function AlbumListPage() {
}
return (
-
+
('grid')
const { query, pathname } = router
const { sortBy, sortOrder } = query as { sortBy: string, sortOrder: string }
const [albums, setAlbums] = useState([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
- const [selectedAlbumsIds, setSelectedAlbumsIds] = useState([])
const albumShareDialogRef = useRef(null);
const [deleting, setDeleting] = useState(false)
+ const dataTableRef = useRef>(null)
+
+ // Separate selection state for table view
+ const [tableSelectedIds, setTableSelectedIds] = useState([])
+
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
@@ -91,54 +135,73 @@ export default function AlbumListPage() {
else if (errorMessage) {
return {errorMessage}
}
+
+ if (viewMode === 'table') {
+ return (
+
+ row.id}
+ searchValue={search}
+ onSearchChange={setSearch}
+ ref={dataTableRef}
+ />
+
+ )
+ }
+
return (
{searchedAlbums.map((album) => (
handleSelect(checked, album.id)}
+ selected={isSelected(album.id)}
+ onSelect={selectAlbum}
/>
))}
)
}
+
return (
-
+
+ Manage Albums
+ {currentSelectedCount > 0 && (
+
+ ({currentSelectedCount} selected)
+
+ )}
+
+ }
rightComponent={
- {!!selectedAlbumsIds.length && (
- <>
-
{
- albumShareDialogRef.current?.open(selectedAlbums)
- }}>
- Share {selectedAlbumsIds.length} albums
-
-
-
-
- {deleting ? "Deleting..." : `Delete ${selectedAlbumsIds.length} albums`}
-
-
- >
- )}
setSearch(e.target.value)} />
+
+
+
setViewMode('grid')}
+ className="rounded-r-none"
+ >
+
+
+
setViewMode('table')}
+ className="rounded-l-none"
+ >
+
+
+
+
router.push({
@@ -171,6 +234,60 @@ export default function AlbumListPage() {
}
/>
{renderContent()}
+ {currentSelectedCount > 0 && (
+
+
+
+ {currentSelectedCount} Selected
+
+
+ {isAllSelectedCurrent ? (
+
+ Clear Selection
+
+ ) : (
+
+ Select All
+
+ )}
+
{
+ albumShareDialogRef.current?.open(currentSelectedAlbums)
+ }}>
+ Share
+
+
+
+
+ {deleting ? "Deleting..." : "Delete"}
+
+
+
+
+
+ )}
diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx
index 322559a..e9cccf6 100644
--- a/src/pages/albums/potential-albums.tsx
+++ b/src/pages/albums/potential-albums.tsx
@@ -112,7 +112,7 @@ export default function PotentialAlbums() {
}
return (
-
+
+ {contextState.selectedIds.length === contextState.assets.length ? (
Unselect all
-
-
@@ -170,10 +171,10 @@ export default function PotentialAlbums() {
selectedIds: contextState.assets.map((a) => a.id),
})
}
-
>
Select all
+ )}
diff --git a/src/pages/api/assets/empty-videos.ts b/src/pages/api/assets/empty-videos.ts
new file mode 100644
index 0000000..e97d877
--- /dev/null
+++ b/src/pages/api/assets/empty-videos.ts
@@ -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
`
+ (
+ 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);
+}
diff --git a/src/pages/api/immich-proxy/[...path].ts b/src/pages/api/immich-proxy/[...path].ts
index 3beaf92..cb195ea 100644
--- a/src/pages/api/immich-proxy/[...path].ts
+++ b/src/pages/api/immich-proxy/[...path].ts
@@ -22,24 +22,49 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Forward the status code
res.status(response.status)
- // Forward the headers
+ // Forward the headers, but filter out problematic ones
const responseHeaders = response.headers
responseHeaders.forEach((value, key) => {
- res.setHeader(key, value)
+ // Skip headers that can cause decoding issues
+ const skipHeaders = [
+ 'content-encoding',
+ 'content-length',
+ 'transfer-encoding',
+ 'connection',
+ 'keep-alive'
+ ]
+
+ if (!skipHeaders.includes(key.toLowerCase())) {
+ res.setHeader(key, value)
+ }
})
- // Only stream the response body if it exists and has content
+ // Handle the response body properly
if (response.body) {
- const reader = response.body.getReader()
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- res.write(value)
+ // For binary content or when we need to preserve encoding
+ if (response.headers.get('content-type')?.includes('image/') ||
+ response.headers.get('content-type')?.includes('video/') ||
+ response.headers.get('content-type')?.includes('audio/') ||
+ response.headers.get('content-type')?.includes('application/octet-stream')) {
+
+ const arrayBuffer = await response.arrayBuffer()
+ res.send(Buffer.from(arrayBuffer))
+ } else {
+ // For text/JSON content, let Next.js handle the encoding
+ const text = await response.text()
+ res.send(text)
}
+ } else {
+ res.end()
}
- res.end()
} catch (error: any) {
console.error('Proxy error:', error)
res.status(500).json({ error: error?.message })
}
+}
+
+export const config = {
+ api: {
+ responseLimit: false,
+ },
}
\ No newline at end of file
diff --git a/src/pages/api/share-link/[token]/assets.ts b/src/pages/api/share-link/[token]/assets.ts
index dee5525..635a8fb 100644
--- a/src/pages/api/share-link/[token]/assets.ts
+++ b/src/pages/api/share-link/[token]/assets.ts
@@ -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 {
diff --git a/src/pages/assets/bulk-duplicate-finder.tsx b/src/pages/assets/bulk-duplicate-finder.tsx
new file mode 100644
index 0000000..3623ee6
--- /dev/null
+++ b/src/pages/assets/bulk-duplicate-finder.tsx
@@ -0,0 +1,328 @@
+import { listDuplicates, deleteAssets, updateAssets } from "@/handlers/api/asset.handler";
+import { IDuplicateAssetRecord } from "@/types/asset";
+import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
+import PageLayout from '@/components/layouts/PageLayout'
+import Header from '@/components/shared/Header'
+import VirtualizedDuplicateList from '@/components/assets/duplicate-assets/VirtualizedDuplicateList'
+import Loader from '@/components/ui/loader'
+import { Button } from '@/components/ui/button'
+import { RefreshCw, Search } from 'lucide-react'
+
+import { toast } from '@/components/ui/use-toast'
+import { humanizeBytes } from '@/helpers/string.helper'
+
+export default function BulkDuplicatePage() {
+ const [duplicates, setDuplicates] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedAssets, setSelectedAssets] = useState>(new Set());
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
+ const [containerHeight, setContainerHeight] = useState(600);
+ const containerRef = useRef(null);
+
+ const fetchDuplicates = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const duplicates = await listDuplicates();
+ setDuplicates(duplicates);
+ setSelectedAssets(new Set()); // Clear selection when refetching
+ } catch (error: any) {
+ setError(error.message || 'Failed to load duplicate assets');
+ toast({
+ title: "Error",
+ description: "Failed to load duplicate assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchDuplicates();
+ }, []);
+
+ // Handle container height calculation and resize
+ useEffect(() => {
+ const updateHeight = () => {
+ // Calculate available height: full viewport minus header (48px)
+ const viewportHeight = window.innerHeight;
+ const headerHeight = 48; // h-12 = 48px
+ const availableHeight = viewportHeight - headerHeight;
+ setContainerHeight(availableHeight);
+ };
+
+ // Initial calculation
+ updateHeight();
+
+ // Update on window resize
+ window.addEventListener('resize', updateHeight);
+
+ // Cleanup
+ return () => {
+ window.removeEventListener('resize', updateHeight);
+ };
+ }, [duplicates]); // Re-calculate when duplicates change
+
+ // Flatten all asset IDs for selection operations
+ const allAssetIds = useMemo(() => {
+ return duplicates.flatMap(record => record.assets.map(asset => asset.id));
+ }, [duplicates]);
+
+ const handleAssetSelect = useCallback((assetId: string, isShiftClick?: boolean) => {
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+
+ if (isShiftClick && lastSelectedIndex >= 0) {
+ // Find the indices of the current and last selected assets
+ const currentIndex = allAssetIds.indexOf(assetId);
+ const start = Math.min(lastSelectedIndex, currentIndex);
+ const end = Math.max(lastSelectedIndex, currentIndex);
+
+ // Select all assets between the two indices
+ for (let i = start; i <= end; i++) {
+ newSelection.add(allAssetIds[i]);
+ }
+ setLastSelectedIndex(currentIndex);
+ } else {
+ // Regular selection toggle
+ if (newSelection.has(assetId)) {
+ newSelection.delete(assetId);
+ } else {
+ newSelection.add(assetId);
+ }
+ setLastSelectedIndex(allAssetIds.indexOf(assetId));
+ }
+
+ return newSelection;
+ });
+ }, [allAssetIds, lastSelectedIndex]);
+
+
+
+ const handleDeleteRecord = async (record: IDuplicateAssetRecord) => {
+ setIsDeleting(true);
+ try {
+ const assetIds = record.assets.map(asset => asset.id);
+ const totalSize = record.assets.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0);
+ await deleteAssets(assetIds);
+
+ // Remove the entire record from duplicates
+ setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId));
+
+ // Remove any selected assets from this record
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+ assetIds.forEach(id => newSelection.delete(id));
+ return newSelection;
+ });
+
+ setLastSelectedIndex(-1);
+
+ toast({
+ title: "Success",
+ description: `Successfully deleted ${record.assets.length} duplicate assets and saved ${humanizeBytes(totalSize)} of storage.`,
+ });
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to delete assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleKeepAllInRecord = useCallback(async (record: IDuplicateAssetRecord) => {
+ setIsDeleting(true);
+ try {
+ const assetIds = record.assets.map(asset => asset.id);
+
+ // Update assets to remove duplicateId (set to null)
+ await updateAssets({
+ ids: assetIds,
+ duplicateId: null
+ });
+
+ // Remove the entire record from duplicates
+ setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId));
+
+ // Remove any selected assets from this record
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+ assetIds.forEach(id => newSelection.delete(id));
+ return newSelection;
+ });
+
+ setLastSelectedIndex(-1);
+
+ toast({
+ title: "Success",
+ description: `Successfully kept ${record.assets.length} assets. They will no longer appear as duplicates.`,
+ });
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to keep assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ }, [duplicates]);
+
+ const handleKeepSelected = async (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => {
+ setIsDeleting(true);
+ try {
+ const unselectedSize = record.assets
+ .filter(asset => unselectedIds.includes(asset.id))
+ .reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0);
+
+ // First, update selected assets to remove duplicateId (mark as kept)
+ if (selectedIds.length > 0) {
+ await updateAssets({
+ ids: selectedIds,
+ duplicateId: null
+ });
+ }
+
+ // Then, delete unselected assets
+ if (unselectedIds.length > 0) {
+ await deleteAssets(unselectedIds);
+ }
+
+ // Remove the entire record from duplicates (since selected assets are no longer duplicates)
+ setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId));
+
+ // Remove all assets from this record from selection
+ setSelectedAssets(prev => {
+ const newSelection = new Set(prev);
+ [...selectedIds, ...unselectedIds].forEach(id => newSelection.delete(id));
+ return newSelection;
+ });
+
+ setLastSelectedIndex(-1);
+
+ toast({
+ title: "Success",
+ description: `Successfully kept ${selectedIds.length} assets and deleted ${unselectedIds.length} duplicates. Storage saved: ${humanizeBytes(unselectedSize)}.`,
+ });
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to process selected assets. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+
+
+ return (
+
+
+
+ Refresh
+
+ }
+ />
+
+
+ {/* Description - only show when loading or no duplicates */}
+ {(loading || error || duplicates.length === 0) && (
+
+
+ Manage and remove duplicate assets from your library
+
+
+ )}
+
+ {loading && (
+
+
+
+ Scanning for duplicate assets...
+
+
+ )}
+
+ {error && (
+
+
+
+ Error:
+ {error}
+
+
+ Try Again
+
+
+
+ )}
+
+ {!loading && !error && duplicates.length === 0 && (
+
+
+
+ No duplicates found
+
+
+ Great! No duplicate assets were detected in your library.
+
+
+ )}
+
+ {!loading && !error && duplicates.length > 0 && (
+
+
+
+ )}
+
+
+
+
+ {isDeleting && (
+
+
+
+
+
+
+ Deleting Assets
+
+
+ Removing duplicate assets...
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/pages/assets/empty-videos.tsx b/src/pages/assets/empty-videos.tsx
new file mode 100644
index 0000000..6347ab1
--- /dev/null
+++ b/src/pages/assets/empty-videos.tsx
@@ -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([])
+ 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({
+ selectedIds: [],
+ assets: [],
+ config: {
+ albumId: '',
+ sort: "fileOriginalDate",
+ sortOrder: "asc"
+ },
+ updateContext: (newConfig: Partial) => {
+ 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
+ }
+ else if (errorMessage) {
+ return {errorMessage}
+ }
+ return (
+
+ {assets.length === 0 ? (
+
+ No empty videos found with the current filters.
+
+ ) : (
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+ )
+ }
+ />
+
+ {renderContent()}
+ {selectedAssets.length > 0 &&
+
+
+
+ {contextState.selectedIds.length} Selected
+
+
+ {contextState.selectedIds.length === contextState.assets.length ? (
+
+ contextState.updateContext({
+ selectedIds: [],
+ })
+ }
+ >
+ Unselect all
+
+ ) : (
+
+ contextState.updateContext({
+ selectedIds: contextState.assets.map((a) => a.id),
+ })
+ }
+ >
+ Select all
+
+ )}
+
+
+
+
+ Delete
+
+
+
+
+
+ }
+
+
+ )
+}
diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx
index f037bd7..98a9613 100644
--- a/src/pages/assets/missing-locations.tsx
+++ b/src/pages/assets/missing-locations.tsx
@@ -94,7 +94,7 @@ export default function MissingLocations() {
}
return (
-
+
+
{geminiEnabled ? (
<>
diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts
index 71af4aa..71f4fdb 100644
--- a/src/types/asset.d.ts
+++ b/src/types/asset.d.ts
@@ -24,3 +24,64 @@ export interface IAssetThumbhash {
type: string;
data: number[];
}
+
+
+export interface IDuplicateAssetRecord {
+ duplicateId: string;
+ assets: IDuplicateAsset[];
+}
+
+export interface IDuplicateAsset {
+ id: string;
+ deviceAssetId: string;
+ ownerId: string;
+ deviceId: string;
+ libraryId: null;
+ type: string;
+ originalPath: string;
+ originalFileName: string;
+ originalMimeType: string;
+ thumbhash: string;
+ fileCreatedAt: Date;
+ fileModifiedAt: Date;
+ localDateTime: Date;
+ updatedAt: Date;
+ isFavorite: boolean;
+ isArchived: boolean;
+ isTrashed: boolean;
+ visibility: string;
+ duration: string;
+ exifInfo: IDuplicateAssetExifInfo;
+ livePhotoVideoId: null;
+ people: any[];
+ checksum: string;
+ isOffline: boolean;
+ hasMetadata: boolean;
+ duplicateId: string;
+ resized: boolean;
+}
+
+export interface IDuplicateAssetExifInfo {
+ make: string;
+ model: string;
+ exifImageWidth: number;
+ exifImageHeight: number;
+ fileSizeInByte: number;
+ orientation: string;
+ dateTimeOriginal: Date;
+ modifyDate: Date;
+ timeZone: string;
+ lensModel: string;
+ fNumber: number;
+ focalLength: number;
+ iso: number;
+ exposureTime: string;
+ latitude: number;
+ longitude: number;
+ city: string;
+ state: string;
+ country: string;
+ description: string;
+ projectionType: null;
+ rating: null;
+}