diff --git a/app/views/Galleries/GalleryForm/index.tsx b/app/views/Galleries/GalleryForm/index.tsx index f003cfb..4a5e8cb 100644 --- a/app/views/Galleries/GalleryForm/index.tsx +++ b/app/views/Galleries/GalleryForm/index.tsx @@ -13,6 +13,7 @@ import { import { BlockLoading, Button, + Checkbox, Container, Heading, IconButton, @@ -78,7 +79,7 @@ const GalleryAlbumSchema: FormSchema = { const defaultEditFormValue: PartialFormType = {}; // Number of images fetched per page in the edit view. -const IMAGES_PER_PAGE = 25; +const IMAGES_PER_PAGE = 10; type GalleryImage = GalleryImageListQuery['galleryImages']['results'][number]; @@ -86,6 +87,11 @@ function getDisplayName(name: string) { return name.split('/').pop()?.replace(/\.[^.]+$/, '') ?? name; } +interface NewImage { + file: File; + preview: string; +} + interface ExistingImage { id: string; name: string; @@ -98,9 +104,10 @@ function GalleryForm() { const navigate = useRouting(); const alert = useAlert(); - const [newFiles, setNewFiles] = useState([]); + const [newImages, setNewImages] = useState([]); const [fileErrors, setFileErrors] = useState([]); const [removedImageIds, setRemovedImageIds] = useState([]); + const [selectedImageIds, setSelectedImageIds] = useState([]); const [submitting, setSubmitting] = useState(false); const { @@ -170,32 +177,25 @@ function GalleryForm() { const loadedImagesCount = loadedImages.length; const hasMoreImages = loadedImagesCount < totalImages; - const loadMoreRef = useRef(null); + const handleLoadMoreClick = useCallback(() => { + setImagesOffset(loadedImagesCount); + }, [loadedImagesCount]); - useEffect(() => { - const loadMoreElement = loadMoreRef.current; - if (isNotDefined(loadMoreElement) || !hasMoreImages || imagesFetch) { - return undefined; + const topUpLoadedImages = useCallback((remainingCount: number) => { + if (!imagesFetch && hasMoreImages && remainingCount < IMAGES_PER_PAGE) { + setImagesOffset(loadedImagesCount); } - const observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - setImagesOffset(loadedImagesCount); - } - }, { rootMargin: '300px' }); - observer.observe(loadMoreElement); - return () => { - observer.disconnect(); - }; - }, [hasMoreImages, imagesFetch, loadedImagesCount]); - - const newFilePreviews = useMemo( - () => newFiles.map((file) => URL.createObjectURL(file)), - [newFiles], - ); + }, [imagesFetch, hasMoreImages, loadedImagesCount]); + + const previewUrlsRef = useRef([]); + + useEffect(() => { + previewUrlsRef.current = newImages.map(({ preview }) => preview); + }, [newImages]); useEffect(() => () => { - newFilePreviews.forEach((url) => URL.revokeObjectURL(url)); - }, [newFilePreviews]); + previewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + }, []); const handleFilesSelect = useCallback((files: File[] | undefined) => { if (isNotDefined(files) || files.length === 0) { @@ -211,19 +211,50 @@ function GalleryForm() { return true; }); setFileErrors(rejected); - if (accepted.length > 0) { - setNewFiles((prev) => [...prev, ...accepted]); + if (accepted.length === 0) { + return; } + const acceptedWithPreviews = accepted.map((file) => ({ + file, + preview: URL.createObjectURL(file), + })); + setNewImages((prev) => [...prev, ...acceptedWithPreviews]); }, []); const handleRemoveExistingImage = useCallback((imageId: string) => { setRemovedImageIds((prev) => [...prev, imageId]); + setSelectedImageIds((prev) => prev.filter((selectedId) => selectedId !== imageId)); + topUpLoadedImages(existingImages.length - 1); + }, [existingImages.length, topUpLoadedImages]); + + const handleRemoveNewImage = useCallback((index: number) => { + setNewImages((prev) => { + URL.revokeObjectURL(prev[index].preview); + return prev.filter((_, imageIndex) => imageIndex !== index); + }); }, []); - const handleRemoveNewFile = useCallback((index: number) => { - setNewFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); + const handleImageSelect = useCallback((selected: boolean, imageId: string) => { + setSelectedImageIds((prev) => ( + selected + ? [...prev, imageId] + : prev.filter((selectedId) => selectedId !== imageId) + )); }, []); + const allImagesSelected = existingImages.length > 0 + && selectedImageIds.length === existingImages.length; + + const handleSelectAllChange = useCallback((selected: boolean) => { + setSelectedImageIds(selected ? existingImages.map((image) => image.id) : []); + }, [existingImages]); + + const handleRemoveSelectedClick = useCallback(() => { + setRemovedImageIds((prev) => [...prev, ...selectedImageIds]); + setSelectedImageIds([]); + topUpLoadedImages(existingImages.length - selectedImageIds.length); + }, [selectedImageIds, existingImages.length, topUpLoadedImages]); + const handleGalleryImages = useCallback(async (albumId: string) => { const deleteResponses = await Promise.all( removedImageIds.map((imageId) => deleteImagesFromGallery({ id: imageId })), @@ -233,7 +264,7 @@ function GalleryForm() { ); const uploadResponses = await Promise.all( - newFiles.map((file, index) => addImagesToGallery({ + newImages.map(({ file }, index) => addImagesToGallery({ data: { album: albumId, image: file, @@ -266,7 +297,7 @@ function GalleryForm() { deleteImagesFromGallery, removedImageIds, existingImages, - newFiles, + newImages, ]); const handleCreateGallery = useCallback(async (formData: PartialFormType) => { @@ -445,12 +476,43 @@ function GalleryForm() { Click to upload images + {existingImages.length > 0 && ( + + 0 && !allImagesSelected} + onChange={handleSelectAllChange} + label={`Select all (${selectedImageIds.length}/${existingImages.length})`} + disabled={submitting} + /> + + + )} {existingImages.map((image) => (
+ )} - size="sm" + size="md" + withContainedFit />
))} - {newFiles.map((file, index) => ( + {newImages.map(({ file, preview }, index) => (
{file.name} @@ -498,7 +560,7 @@ function GalleryForm() { name={index} title="Remove image" ariaLabel="Remove image" - onClick={handleRemoveNewFile} + onClick={handleRemoveNewImage} variant="secondary" disabled={submitting} > @@ -508,17 +570,14 @@ function GalleryForm() { ))} {hasMoreImages && ( -
- {imagesFetch && ( - - )} +
+
)} {fileErrors.map((fileError) => ( diff --git a/app/views/Galleries/GalleryForm/styles.module.css b/app/views/Galleries/GalleryForm/styles.module.css index 8408877..a432e0f 100644 --- a/app/views/Galleries/GalleryForm/styles.module.css +++ b/app/views/Galleries/GalleryForm/styles.module.css @@ -42,6 +42,8 @@ } .load-more { + display: flex; + justify-content: center; min-height: 1rem; } @@ -54,6 +56,25 @@ border-radius: var(--go-ui-border-radius-lg); } +.file-item .select-checkbox { + position: absolute; + top: var(--go-ui-spacing-2xs); + left: var(--go-ui-spacing-2xs); + transition: opacity 0.2s ease; + opacity: 0; + z-index: 1; + border-radius: var(--go-ui-border-radius-sm); + background-color: var(--go-ui-color-foreground); + padding: var(--go-ui-spacing-3xs); + font-size: var(--go-ui-font-size-sm); +} + +.file-item:hover .select-checkbox, +.file-item .select-checkbox:focus-within, +.file-item .select-checkbox.selected { + opacity: 1; +} + .file-item .remove-button { position: absolute; top: var(--go-ui-spacing-xs);