feat: bulk delete in find page and find page clean up
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
+4
-3
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user