feat: bulk delete in find page and find page clean up

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-01-19 09:31:35 +05:30
parent b60660fe2e
commit 2150aca154
12 changed files with 156 additions and 49 deletions
@@ -55,7 +55,6 @@ export default function AlbumSelectorDialog({ onSelected, onCreated, onSubmit }:
const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log(formData);
if (!onSubmit) return;
setLoading(true);
onSubmit(formData)
@@ -68,7 +68,7 @@ export default function AssetOffsetDialog({ assets: _assets, onComplete }: IProp
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={"default"} size={"sm"} onClick={() => setOpen(true)}>
<Button variant={"default"} size={"sm"} onClick={() => setOpen(true)} disabled={assets.length === 0}>
Offset Dates
</Button>
</DialogTrigger>
@@ -13,6 +13,7 @@ import React, { useState } from "react";
import TagMissingLocationSearchAndAdd from "./TagMissingLocationSearchAndAdd";
import TagMissingLocationSearchLatLong from "./TagMissingLocationSearchLatLong";
import dynamic from "next/dynamic";
import { useMissingLocationContext } from "@/contexts/MissingLocationContext";
const LazyMap = dynamic(() => import("./Map"), {
ssr: false
@@ -24,7 +25,7 @@ interface ITagMissingLocationDialogProps {
export default function TagMissingLocationDialog({
onSubmit,
}: ITagMissingLocationDialogProps) {
const {selectedIds} = useMissingLocationContext();
const [open, setOpen] = useState(false);
const [mapPosition,setMapPosition] = useState<IPlace>({
latitude: 48.0,
@@ -35,8 +36,8 @@ export default function TagMissingLocationDialog({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size={"sm"}>Tag Location</Button>
<DialogTrigger asChild >
<Button size={"sm"} disabled={selectedIds.length === 0}>Tag Location</Button>
</DialogTrigger>
<DialogContent className="!w-[600px] !max-w-[600px]">
<DialogHeader>
+49 -4
View File
@@ -1,7 +1,7 @@
import "yet-another-react-lightbox/styles.css";
import { IAsset } from '@/types/asset';
import React, { useMemo, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import Lightbox from 'yet-another-react-lightbox';
import { Gallery } from "react-grid-gallery";
import LazyGridImage from "../ui/lazy-grid-image";
@@ -12,10 +12,40 @@ interface AssetGridProps {
assets: IAsset[];
isInternal?: boolean;
selectable?: boolean;
onSelectionChange?: (ids: string[]) => void;
}
export default function AssetGrid({ assets, isInternal = true, selectable = false }: AssetGridProps) {
export default function AssetGrid({ assets, isInternal = true, selectable = false, onSelectionChange }: AssetGridProps) {
const [index, setIndex] = useState(-1);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
const handleSelect = (_idx: number, asset: IAsset, event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
const isPresent = selectedIds.includes(asset.id);
if (isPresent) {
setSelectedIds(selectedIds.filter((id) => id !== asset.id));
onSelectionChange?.(selectedIds.filter((id) => id !== asset.id));
} else {
const clickedIndex = images.findIndex((image) => {
return image.id === asset.id;
});
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 uniqueSelectedIds = [...new Set(allSelectedIds)];
setSelectedIds(uniqueSelectedIds);
onSelectionChange?.(uniqueSelectedIds);
} else {
setSelectedIds([...selectedIds, asset.id]);
onSelectionChange?.([...selectedIds, asset.id]);
}
setLastSelectedIndex(clickedIndex);
}
};
const slides = useMemo(() => {
return assets.map((asset) => ({
@@ -45,9 +75,23 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
original: p.previewUrl as string,
width: p.exifImageWidth / 10 as number,
height: p.exifImageHeight / 10 as number,
orientation: 1
orientation: 1,
isSelected: selectedIds.includes(p.id),
}));
}, [assets]);
}, [assets, selectedIds]);
const handleEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSelectedIds([]);
onSelectionChange?.([]);
}
};
useEffect(() => {
// Listen for esc key press and unselect all images
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [images]);
return (
<div>
@@ -63,6 +107,7 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
onClick={setIndex}
enableImageSelection={selectable}
thumbnailImageComponent={LazyGridImage}
onSelect={handleSelect}
/>
</div>
);
@@ -0,0 +1,36 @@
import React, { useMemo } from 'react'
import { Button } from '../ui/button';
import { AlertDialog } from '../ui/alert-dialog';
import { deleteAssets } from '@/handlers/api/asset.handler';
interface AssetsBulkDeleteButtonProps {
selectedIds: string[];
onDelete: (ids: string[]) => void;
}
export default function AssetsBulkDeleteButton({ selectedIds, onDelete }: AssetsBulkDeleteButtonProps) {
const ids = useMemo(() => {
return selectedIds
}, [selectedIds]);
const handleDelete = () => {
return deleteAssets(ids).then(() => {
onDelete(ids);
})
}
return (
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}
disabled={ids.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={ids.length === 0}>
Delete
</Button>
</AlertDialog>
)
}
+4 -2
View File
@@ -139,6 +139,7 @@ interface AlertDialogProps extends AlertDialogPrimitive.AlertDialogProps {
onCancel: () => void | Promise<void>
variant?: ButtonProps['variant']
asChild?: boolean
disabled?: boolean
}
const AlertDialog = React.forwardRef<INewChangelogButtonActions, Partial<AlertDialogProps>>(({
@@ -149,6 +150,7 @@ const AlertDialog = React.forwardRef<INewChangelogButtonActions, Partial<AlertDi
onCancel,
variant,
asChild,
disabled,
...props
}, ref) => {
const [loading, setLoading] = useState(false)
@@ -169,7 +171,7 @@ const AlertDialog = React.forwardRef<INewChangelogButtonActions, Partial<AlertDi
return (
<AlertDialogRoot {...props} open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild={asChild}>{children}</AlertDialogTrigger>
<AlertDialogTrigger disabled={disabled} asChild={asChild}>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
@@ -178,7 +180,7 @@ const AlertDialog = React.forwardRef<INewChangelogButtonActions, Partial<AlertDi
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={loading}
disabled={loading || disabled}
onClick={handleConfirm}
variant={variant}
>
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
export default function FourOFour() {
return (
<div className="flex flex-col items-center justify-center h-screen">
<h1 className="text-4xl font-bold">404</h1>
<p className="text-lg text-muted-foreground">Page not found</p>
</div>
)
}
+4 -2
View File
@@ -161,8 +161,10 @@ export default function PotentialAlbums() {
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}>
<Button variant={"destructive"} size={"sm"} >
onConfirm={handleDelete}
disabled={config.selectedIds.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={config.selectedIds.length === 0}>
Delete
</Button>
</AlertDialog>
+11 -2
View File
@@ -1,6 +1,7 @@
import { db } from '@/config/db';
import { ENV } from '@/config/environment';
import { getCurrentUser } from '@/handlers/serverUtils/user.utils';
import { cleanUpAsset, cleanUpAssets, isFlipped } from '@/helpers/asset.helper';
import { parseFindQuery } from '@/helpers/gemini.helper';
import { assetFaces, assets, person } from '@/schema';
import { Person } from '@/schema/person.schema';
@@ -23,7 +24,7 @@ export default async function search(req: NextApiRequest, res: NextApiResponse)
return fetch(url, {
method: 'POST',
body: JSON.stringify(parsedQuery),
body: JSON.stringify({...parsedQuery, withExif: true }),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentUser?.accessToken}`
@@ -40,8 +41,16 @@ export default async function search(req: NextApiRequest, res: NextApiResponse)
return response.json();
})
.then(data => {
const items = data.assets.items.map((item: any) => {
return {
...item,
exifImageHeight: isFlipped(item?.exifInfo?.orientation) ? item?.exifInfo?.exifImageWidth : item?.exifInfo?.exifImageHeight,
exifImageWidth: isFlipped(item?.exifInfo?.orientation) ? item?.exifInfo?.exifImageHeight : item?.exifInfo?.exifImageWidth,
orientation: item?.exifInfo?.orientation,
}
}).map(cleanUpAsset)
res.status(200).json({
assets: data.assets.items,
assets: items,
filters: {
...parsedQuery,
personIds: dbPeople.map((person) => person.name)
-1
View File
@@ -10,7 +10,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const currentUser = await getCurrentUser(req);
const filters = req.body;
console.log(filters);
if (!currentUser) {
return res.status(401).json({ message: "Unauthorized" });
}
+4 -2
View File
@@ -141,8 +141,10 @@ export default function MissingLocations() {
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}>
<Button variant={"destructive"} size={"sm"} >
onConfirm={handleDelete}
disabled={config.selectedIds.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={config.selectedIds.length === 0}>
Delete
</Button>
</AlertDialog>
+33 -31
View File
@@ -13,6 +13,9 @@ import React, { useMemo, useState } from 'react'
import Lightbox from 'yet-another-react-lightbox';
import { Button } from "@/components/ui/button";
import Link from "next/link";
import AssetGrid from "@/components/shared/AssetGrid";
import FloatingBar from "@/components/shared/FloatingBar";
import AssetsBulkDeleteButton from "@/components/shared/AssetsBulkDeleteButton";
interface IFindFilters {
[key: string]: string;
@@ -30,7 +33,7 @@ const FILTER_KEY_MAP = {
}
export default function FindPage() {
const [index, setIndex] = useState(-1);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const { geminiEnabled, exImmichUrl } = useConfig();
const [query, setQuery] = useState('');
const [assets, setAssets] = useState<IAsset[]>([]);
@@ -52,7 +55,12 @@ export default function FindPage() {
value: string;
}[] = useMemo(() => {
return Object.entries(filters)
.filter(([_key, value]) => value !== undefined)
.filter(([_key, value]) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== undefined && value !== null && value !== '';
})
.map(([key, value]) => ({
label: key,
value: Array.isArray(value) ? value.join(', ') : value,
@@ -73,6 +81,16 @@ export default function FindPage() {
});
}
const handleSelectionChange = (ids: string[]) => {
setSelectedIds(ids);
}
const handleDelete = (ids: string[]) => {
setSelectedIds([]);
setAssets(assets.filter((asset) => !ids.includes(asset.id)));
}
const renderFilters = () => {
if (appliedFilters.length === 0) return null;
return (
@@ -114,41 +132,25 @@ export default function FindPage() {
<p className='text-sm text-muted-foreground'>
{renderFilters()}
</p>
</div>
}
return (
<>
<Lightbox
slides={slides}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
{renderFilters()}
<div className="grid grid-cols-5 gap-4 p-2">
{assets.map((asset, idx) => (
<div key={asset.id} className='group w-full h-[200px] rounded-md overflow-hidden relative'>
<Link href={`${exImmichUrl}/photos/${asset.id}`}
target="_blank"
className='absolute top-2 right-2 text-xs bg-white dark:bg-zinc-800 px-2 py-1 rounded-md flex gap-1 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300'>
Open
<ArrowUpRight className='w-4 h-4' />
</Link>
<Image
src={ASSET_THUMBNAIL_PATH(asset.id)}
alt={asset.id}
style={{
objectFit: 'cover',
width: '100%',
height: '100%',
}}
width={200}
height={200}
onClick={() => setIndex(idx)}
<div className="w-full">
<AssetGrid assets={assets} selectable onSelectionChange={handleSelectionChange} />
{selectedIds.length > 0 && (
<FloatingBar>
<p className="text-sm text-muted-foreground">
{selectedIds.length} Selected
</p>
<AssetsBulkDeleteButton
selectedIds={selectedIds}
onDelete={handleDelete}
/>
</div>
))}
</FloatingBar>
)}
</div>
</>
)