diff --git a/src/config.js b/src/config.js index 700db5a8..bf680155 100644 --- a/src/config.js +++ b/src/config.js @@ -16,8 +16,9 @@ const stage = import.meta.env.VITE_STAGE || process.env.NODE_ENV; export const API_URL = API_URLS[stage]; export const IMAGES_URL = IMAGES_URLS[stage]; export const IMAGE_QUERY_LIMITS = [10, 50, 100]; -export const IMAGE_DELETE_LIMIT = 100; - +export const SYNC_IMAGE_DELETE_LIMIT = 300; // when deleting w/o using task handler +export const ASYNC_IMAGE_DELETE_BY_ID_LIMIT = 4000; // when deleting using task handler (by _id). Constrained by POST request size limits +export const ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT = 200000; // when deleting using task handler (by filter). Constrained by task Lambda timeout export const SUPPORTED_WIRELESS_CAMS = ['BuckEyeCam', 'RidgeTec', 'CUDDEBACK', 'RECONYX']; diff --git a/src/features/filters/FiltersPanelFooterDropdown.jsx b/src/features/filters/FiltersPanelFooterDropdown.jsx index 469e0b8b..783e5fa3 100644 --- a/src/features/filters/FiltersPanelFooterDropdown.jsx +++ b/src/features/filters/FiltersPanelFooterDropdown.jsx @@ -40,12 +40,12 @@ const FiltersPanelFooterDropdown = (props) => { {hasRole(userRoles, EXPORT_DATA_ROLES) && ( props.handleModalToggle('export-modal')}> - Export filtered data + Export currently filtered data )} {hasRole(userRoles, DELETE_IMAGES_ROLES) && ( - Delete filtered images + Delete all currently filtered images )} diff --git a/src/features/images/DeleteImagesAlert.jsx b/src/features/images/DeleteImagesAlert.jsx new file mode 100644 index 00000000..2a3d3b91 --- /dev/null +++ b/src/features/images/DeleteImagesAlert.jsx @@ -0,0 +1,237 @@ +import React, { useState, useEffect } from 'react'; +import { styled } from '../../theme/stitches.config'; +import { useDispatch, useSelector } from 'react-redux'; +import { + deleteImages, + selectImagesCountLoading, + selectDeleteImagesAlertState, + setDeleteImagesAlertStatus, + selectImagesLoading, + selectImagesCount, +} from './imagesSlice.js'; +import { selectActiveFilters } from '../filters/filtersSlice.js'; +import { selectSelectedImages } from '../review/reviewSlice.js'; +import { + Alert, + AlertPortal, + AlertOverlay, + AlertContent, + AlertTitle, +} from '../../components/AlertDialog.jsx'; +import Button from '../../components/Button.jsx'; +import { red, green } from '@radix-ui/colors'; +import { deleteImagesTask, fetchTask, selectDeleteImagesLoading } from '../tasks/tasksSlice.js'; +import { + SYNC_IMAGE_DELETE_LIMIT, + ASYNC_IMAGE_DELETE_BY_ID_LIMIT, + ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT, +} from '../../config.js'; +import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner.jsx'; +import * as Progress from '@radix-ui/react-progress'; + +const ProgressBar = styled('div', { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + position: 'absolute', + bottom: 0, + width: '100%', +}); + +const ProgressRoot = styled(Progress.Root, { + overflow: 'hidden', + background: '$backgroundDark', + // borderRadius: '99999px', + width: '100%', + height: '8px', + + /* Fix overflow clipping in Safari */ + /* https://gist.github.com/domske/b66047671c780a238b51c51ffde8d3a0 */ + transform: 'translateZ(0)', +}); + +const ProgressIndicator = styled(Progress.Indicator, { + backgroundColor: green.green9, //sky.sky4, //'$blue600', + width: '100%', + height: '100%', + transition: 'transform 660ms cubic-bezier(0.65, 0, 0.35, 1)', +}); + +const DeleteImagesAlert = () => { + const dispatch = useDispatch(); + const alertState = useSelector(selectDeleteImagesAlertState); + const selectedImages = useSelector(selectSelectedImages); + const selectedImageIds = selectedImages.map((img) => img._id); + + const imagesLoading = useSelector(selectImagesLoading); + const deleteImagesTaskLoading = useSelector(selectDeleteImagesLoading); + + const filters = useSelector(selectActiveFilters); + const imageCountIsLoading = useSelector(selectImagesCountLoading); + const imageCount = useSelector(selectImagesCount); + + useEffect(() => { + if (deleteImagesTaskLoading.isLoading && deleteImagesTaskLoading.taskId) { + dispatch(fetchTask(deleteImagesTaskLoading.taskId)); + } + }, [deleteImagesTaskLoading, dispatch]); + + const [estimatedTotalTime, setEstimatedTotalTime] = useState(null); // in seconds + const [elapsedTime, setElapsedTime] = useState(null); + + const handleConfirmDelete = () => { + if (alertState.deleteImagesAlertByFilter) { + // if deleting by filter, always delete using task handler + dispatch(deleteImagesTask({ imageIds: [], filters: filters })); + } else { + // if deleting by selection of IDs, use task handler if over limit + if (selectedImages.length > SYNC_IMAGE_DELETE_LIMIT) { + dispatch(deleteImagesTask({ imageIds: selectedImageIds, filters: null })); + } else { + dispatch(deleteImages(selectedImageIds)); + } + } + if (selectedImages.length > 3000 || imageCount > 3000) { + // show progress bar if deleting more than 3000 images (approx wait time will be > 10 seconds) + const count = !alertState.deleteImagesAlertByFilter ? selectedImages.length : imageCount; + setEstimatedTotalTime(count * 0.0055); // estimated deletion time per image in seconds + setElapsedTime(0); + } + }; + + useEffect(() => { + if (estimatedTotalTime) { + const interval = setInterval(() => { + setElapsedTime((prevElapsedTime) => { + if (prevElapsedTime >= estimatedTotalTime) { + clearInterval(interval); + setEstimatedTotalTime(null); + setElapsedTime(null); + return estimatedTotalTime; + } + return prevElapsedTime + 1; + }); + }, 1000); + return () => clearInterval(interval); + } + }, [estimatedTotalTime, elapsedTime]); + + const handleCancelDelete = () => { + dispatch(setDeleteImagesAlertStatus({ openStatus: false })); + }; + + const deleteByIdLimitExceeded = + !alertState.deleteImagesAlertByFilter && selectedImages.length > ASYNC_IMAGE_DELETE_BY_ID_LIMIT; + const byFilterLimitExceeded = + alertState.deleteImagesAlertByFilter && imageCount > ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT; + + const isSpinnerActive = + (deleteImagesTaskLoading.isLoading && deleteImagesTaskLoading.taskId) || + (alertState.deleteImagesAlertByFilter && imageCountIsLoading.isLoading) || + imagesLoading.isLoading; + + const filterTitle = `Are you sure you'd like to delete ${imageCount === 1 ? 'this image' : `these ${imageCount && imageCount.toLocaleString()} images`}?`; + const selectionTitle = `Are you sure you'd like to delete ${selectedImages.length === 1 ? 'this image' : `these ${selectedImages && selectedImages.length.toLocaleString()} images`}?`; + const filterText = ( +
+

+ This will delete all images that match the currently applied filters. This action can not be + undone. +

+
+ ); + const selectionText = ( +
+

This will delete all currently selected images. This action can not be undone.

+
+ ); + + const deleteByIdLimitAlertTitle = 'Delete Limit Exceeded'; + const deleteByIdLimitAlertText = ( +
+

+ You have selected {selectedImages.length.toLocaleString()} images, which is more than the{' '} + {ASYNC_IMAGE_DELETE_BY_ID_LIMIT.toLocaleString()} image limit Animl supports when deleting + individually-selected images. +

+ {/*TODO: Add a link to the documentation for more information on how to delete images.*/} +

+ Please select fewer images, or use the delete-by-filter option, which can accommodate + deleting up to {ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT.toLocaleString()} images at a time. +

+
+ ); + + const deleteByFilterLimitAlertTitle = 'Delete Limit Exceeded'; + const deleteByFilterLimitAlertText = ( +
+

+ There are {imageCount?.toLocaleString()} images that match the currently selected filters, + which is more than the {ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT.toLocaleString()} image limit + Animl supports when deleting images by filter. +

+

+ To delete all of the currently matching images, you may need to apply additional filters to + stay within the limit and perform multiple separate deletion requests. +

+
+ ); + + let title = alertState.deleteImagesAlertByFilter ? filterTitle : selectionTitle; + let text = alertState.deleteImagesAlertByFilter ? filterText : selectionText; + if (deleteByIdLimitExceeded) { + title = deleteByIdLimitAlertTitle; + text = deleteByIdLimitAlertText; + } else if (byFilterLimitExceeded) { + title = deleteByFilterLimitAlertTitle; + text = deleteByFilterLimitAlertText; + } + + return ( + + + + + {isSpinnerActive && ( + + + + + + + + + )} + {title} + {text} +
+ + +
+
+
+
+ ); +}; + +export default DeleteImagesAlert; diff --git a/src/features/images/ImagesTable.jsx b/src/features/images/ImagesTable.jsx index d5d47355..ee09accf 100644 --- a/src/features/images/ImagesTable.jsx +++ b/src/features/images/ImagesTable.jsx @@ -11,14 +11,23 @@ import { FixedSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; import AutoSizer from 'react-virtualized-auto-sizer'; import ImagesTableRow from './ImagesTableRow.jsx'; -import { sortChanged, selectImagesLoading, selectPaginatedField, selectSortAscending } from './imagesSlice'; -import { selectFocusIndex, selectFocusChangeType, selectSelectedImageIndices } from '../review/reviewSlice'; +import { + sortChanged, + selectImagesLoading, + selectPaginatedField, + selectSortAscending, +} from './imagesSlice'; +import { + selectFocusIndex, + selectFocusChangeType, + selectSelectedImageIndices, +} from '../review/reviewSlice'; import { selectLoupeOpen } from '../loupe/loupeSlice'; import { Image } from '../../components/Image'; import LabelPills from './LabelPills'; import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner'; import { selectProjectsLoading } from '../projects/projectsSlice'; -import DeleteImagesAlert from '../loupe/DeleteImagesAlert.jsx'; +import DeleteImagesAlert from './DeleteImagesAlert.jsx'; import { columnConfig, columnsToHideMap, defaultColumnDims, tableBreakpoints } from './config'; // TODO: make table horizontally scrollable on smaller screens @@ -171,7 +180,9 @@ const StyledReviewIcon = styled('div', { }); const ReviewedIcon = ({ reviewed }) => ( - {reviewed ? : } + + {reviewed ? : } + ); const ImagesTable = ({ workingImages, hasNext, loadNextPage }) => { @@ -319,17 +330,29 @@ const ImagesTable = ({ workingImages, hasNext, loadNextPage }) => { )} - {imagesLoading.noneFound && Rats! We couldn't find any matching images} + {imagesLoading.noneFound && ( + Rats! We couldn't find any matching images + )} {workingImages.length > 0 && (
{headerGroups.map((headerGroup) => ( - + {headerGroup.headers.map((column) => ( - - + + {column.render('Header')} - {column.canSort && (column.isSortedDesc ? : )} + {column.canSort && + (column.isSortedDesc ? : )} ))} @@ -352,11 +375,17 @@ function makeRows(workingImages, focusIndex, selectedImageIndices) { // label pills const labelPills = ( - + ); // date created - const dtOriginal = DateTime.fromISO(img.dateTimeOriginal).toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS); + const dtOriginal = DateTime.fromISO(img.dateTimeOriginal).toLocaleString( + DateTime.DATETIME_SHORT_WITH_SECONDS, + ); // date added const dtAdded = DateTime.fromISO(img.dateAdded).toLocaleString(DateTime.DATE_SHORT); @@ -370,7 +399,7 @@ function makeRows(workingImages, focusIndex, selectedImageIndices) { dtOriginal, dtAdded, reviewedIcon, - ...img + ...img, }; }); } diff --git a/src/features/loupe/DeleteImagesAlert.jsx b/src/features/loupe/DeleteImagesAlert.jsx deleted file mode 100644 index 1edbf998..00000000 --- a/src/features/loupe/DeleteImagesAlert.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { - deleteImages, - selectImagesCount, - selectImagesCountLoading, - selectDeleteImagesAlertState, - setDeleteImagesAlertStatus, - selectImagesLoading, -} from '../images/imagesSlice.js'; -import { selectActiveFilters } from '../filters/filtersSlice.js'; -import { selectSelectedImages } from '../review/reviewSlice.js'; -import { - Alert, - AlertPortal, - AlertOverlay, - AlertContent, - AlertTitle, -} from '../../components/AlertDialog.jsx'; -import Button from '../../components/Button.jsx'; -import { red } from '@radix-ui/colors'; -import { deleteImagesTask, fetchTask, selectDeleteImagesLoading } from '../tasks/tasksSlice.js'; -import { IMAGE_DELETE_LIMIT } from '../../config.js'; -import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner.jsx'; - -const DeleteImagesAlert = () => { - const dispatch = useDispatch(); - const alertState = useSelector(selectDeleteImagesAlertState); - const selectedImages = useSelector(selectSelectedImages); - const selectedImageIds = selectedImages.map((img) => img._id); - - const imagesLoading = useSelector(selectImagesLoading); - const deleteImagesTaskLoading = useSelector(selectDeleteImagesLoading); - - const filters = useSelector(selectActiveFilters); - const imageCountIsLoading = useSelector(selectImagesCountLoading); - const imageCount = useSelector(selectImagesCount); - - useEffect(() => { - if (deleteImagesTaskLoading.isLoading && deleteImagesTaskLoading.taskId) { - dispatch(fetchTask(deleteImagesTaskLoading.taskId)); - } - }, [deleteImagesTaskLoading, dispatch]); - - const handleConfirmDelete = () => { - if (alertState.deleteImagesAlertByFilter) { - dispatch(deleteImagesTask({ imageIds: [], filters: filters })); - } else { - if (selectedImages.length > IMAGE_DELETE_LIMIT) { - dispatch(deleteImagesTask({ imageIds: selectedImageIds, filters: null })); - } else { - dispatch(deleteImages(selectedImageIds)); - } - } - }; - - const handleCancelDelete = () => { - dispatch(setDeleteImagesAlertStatus({ openStatus: false })); - }; - - const isSpinnerActive = - (deleteImagesTaskLoading.isLoading && deleteImagesTaskLoading.taskId) || - (alertState.deleteImagesAlertByFilter && imageCountIsLoading.isLoading) || - imagesLoading.isLoading; - - const filterText = `Are you sure you'd like to delete ${imageCount === 1 ? 'this image' : `these ${imageCount && imageCount.toLocaleString()} images`}?`; - const selectionText = `Are you sure you'd like to delete ${selectedImages.length === 1 ? 'this image' : `these ${selectedImages && selectedImages.length.toLocaleString()} images`}?`; - return ( - - - - - {isSpinnerActive && ( - - - - )} - - {alertState.deleteImagesAlertByFilter ? filterText : selectionText} - -

This action can not be undone.

-
- - -
-
-
-
- ); -}; - -export default DeleteImagesAlert; diff --git a/src/features/loupe/LoupeDropdown.jsx b/src/features/loupe/LoupeDropdown.jsx index 9265fcec..6180208f 100644 --- a/src/features/loupe/LoupeDropdown.jsx +++ b/src/features/loupe/LoupeDropdown.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { styled } from '../../theme/stitches.config'; import { DropdownMenu, @@ -10,8 +10,9 @@ import { } from '../../components/Dropdown.jsx'; import IconButton from '../../components/IconButton.jsx'; import { DotsHorizontalIcon } from '@radix-ui/react-icons'; -import DeleteImagesAlert from './DeleteImagesAlert.jsx'; +import DeleteImagesAlert from '../images/DeleteImagesAlert.jsx'; import { setDeleteImagesAlertStatus } from '../images/imagesSlice'; +import { selectFocusIndex, setSelectedImageIndices } from '../review/reviewSlice.js'; const StyledDropdownMenuTrigger = styled(DropdownMenuTrigger, { position: 'absolute', @@ -21,8 +22,10 @@ const StyledDropdownMenuTrigger = styled(DropdownMenuTrigger, { const LoupeDropdown = ({ image }) => { const dispatch = useDispatch(); + const focusIndex = useSelector(selectFocusIndex); const handleDeleteImageItemClick = () => { + dispatch(setSelectedImageIndices([focusIndex.image])); dispatch(setDeleteImagesAlertStatus({ openStatus: true, deleteImagesByFilter: false })); }; diff --git a/src/features/upload/BulkUploadForm.jsx b/src/features/upload/BulkUploadForm.jsx index 0950f165..d64e92d3 100644 --- a/src/features/upload/BulkUploadForm.jsx +++ b/src/features/upload/BulkUploadForm.jsx @@ -12,7 +12,6 @@ import { import * as Yup from 'yup'; import Button from '../../components/Button'; import IconButton from '../../components/IconButton.jsx'; -// import ProgressBar from '../../components/ProgressBar'; import { Alert, AlertPortal,