From 8e4ad8b3a1fe56b77acde552fdd769e22e4cc72f Mon Sep 17 00:00:00 2001 From: BFlat Date: Tue, 10 Feb 2026 23:52:39 +0700 Subject: [PATCH] bulk delete function (#1034) - in the library/gallery modal, added a checkbox on top left of each item - when checkbox is checked it enters toiggle select mode which allows multi selecting and bulk deleting objects via one delete button on the bottom right of the modal. - added trash can icon to lightbox delete button --- .../src/lib/GalleryDraggableItem.tsx | 294 +++++++++--------- .../gallery-modal/src/lib/gallery-modal.tsx | 209 ++++++++++--- .../lightbox-modal/src/lib/lightbox-modal.tsx | 2 + 3 files changed, 321 insertions(+), 184 deletions(-) diff --git a/frontend/libs/components/gallery-modal/src/lib/GalleryDraggableItem.tsx b/frontend/libs/components/gallery-modal/src/lib/GalleryDraggableItem.tsx index 727d23d742..bc56811de0 100644 --- a/frontend/libs/components/gallery-modal/src/lib/GalleryDraggableItem.tsx +++ b/frontend/libs/components/gallery-modal/src/lib/GalleryDraggableItem.tsx @@ -31,6 +31,9 @@ interface GalleryDraggableItemProps { onDeleted?: (id: string) => void; onEditClicked?: (url: string, media_id?: string) => Promise | void; maxSelections?: number; + bulkSelected?: boolean; + onBulkSelectToggle?: () => void; + bulkSelectionMode?: boolean; } export const GalleryDraggableItem: React.FC = ({ @@ -45,6 +48,9 @@ export const GalleryDraggableItem: React.FC = ({ onDeleted, onEditClicked, maxSelections, + bulkSelected = false, + onBulkSelectToggle, + bulkSelectionMode = false, }) => { const imgRef = useRef(null); const dragStarted = useRef(false); @@ -76,6 +82,11 @@ export const GalleryDraggableItem: React.FC = ({ const handlePointerDown = (event: React.PointerEvent) => { // Disable dragging for video items, but allow clicks in select mode if (item.mediaClass === "video" && mode !== "select") return; + // In bulk selection mode, skip drag — clicks toggle selection + if (bulkSelectionMode) { + dragStarted.current = false; + return; + } dragStarted.current = false; const moveListener = (moveEvent: PointerEvent) => { const dx = moveEvent.pageX - event.pageX; @@ -100,24 +111,83 @@ export const GalleryDraggableItem: React.FC = ({ }; const handlePointerUp = (event: React.PointerEvent) => { - if (!dragStarted.current && (mode === "select" || !disableTooltipAndBadge)) { + // In bulk selection mode, let only handleButtonClick fire onClick + // to avoid double-toggling the selection + if (bulkSelectionMode) return; + if ( + !dragStarted.current && + (mode === "select" || !disableTooltipAndBadge) + ) { onClick(); } }; const handleButtonClick = (event: React.MouseEvent) => { - if (mode === "select" || !disableTooltipAndBadge) { + if (mode === "select" || !disableTooltipAndBadge || bulkSelectionMode) { onClick(); } }; - return ( -
+
+ {!item.thumbnail ? ( +
+ Image not available +
+ ) : ( + {item.label} + )} + {selected && ( +
+ +
+ )} +
+ + ); + + return ( +
{/* dropdown menu */} {mode !== "select" && (
= ({ }} > - } - buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border" - panelClassName="min-w-28 p-1" - closeOnUnhover - > - {(close) => ( -
- {item.mediaClass === "image" && ( + position="bottom" + align="end" + mode="default" + triggerIcon={ + + } + buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border" + panelClassName="min-w-28 p-1" + closeOnUnhover + > + {(close) => ( +
+ {item.mediaClass === "image" && ( + + )} - )} - -
- )} - +
+ )} +
)} - {/* Media class badge on hover */} + {/* Bulk selection checkbox — top-left */} + {mode !== "select" && ( +
e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + onBulkSelectToggle?.(); + }} + > + {bulkSelected && ( + + )} +
+ )} + {/* Media class badge on hover — bottom-left */} {!disableTooltipAndBadge && item.mediaClass && ( -
+
{item.mediaClass === "dimensional" ? "3D" : item.mediaClass}
)} - {/* Tooltip on hover */} - {disableTooltipAndBadge ? ( - - ) : ( + {/* Conditionally wrap with Tooltip — hidden when selecting or in bulk mode */} + {showTooltip ? ( = ({
} > - + {itemButton} + ) : ( + itemButton )}
); diff --git a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx index 5cd82446cf..936b9803de 100644 --- a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx +++ b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx @@ -43,10 +43,17 @@ import { faExpand, faCompress, faArrowsRotate, + faTrashCan, + faXmark, } from "@fortawesome/pro-solid-svg-icons"; import { PopoverMenu } from "@storyteller/ui-popover"; import { SliderV2 } from "@storyteller/ui-sliderv2"; import { Tooltip } from "@storyteller/ui-tooltip"; +import { + showActionReminder, + isActionReminderOpen, +} from "@storyteller/ui-action-reminder-modal"; +import { MediaFileDelete } from "@storyteller/tauri-api"; export interface GalleryItem { id: string; @@ -80,22 +87,22 @@ interface GalleryModalProps { onDownloadClicked?: (url: string, mediaClass?: string) => Promise; onAddToSceneClicked?: ( url: string, - media_id: string | undefined + media_id: string | undefined, ) => Promise; isOpen?: boolean; forceFilter?: string; onEditClicked?: (url: string, media_id?: string) => Promise | void; onTurnIntoVideoClicked?: ( url: string, - media_id?: string + media_id?: string, ) => Promise | void; onRemoveBackgroundClicked?: ( url: string, - media_id?: string + media_id?: string, ) => Promise | void; onMake3DWorldClicked?: ( url: string, - media_id?: string + media_id?: string, ) => Promise | void; } @@ -136,7 +143,7 @@ export const GalleryModal = React.memo( // Default gridColumns to 5 const defaultGridColumns = 5; const [sliderValue, setSliderValue] = useState( - maxColumns - (defaultGridColumns - minColumns) + maxColumns - (defaultGridColumns - minColumns), ); const gridColumns = maxColumns - (sliderValue - minColumns); const [imageFit, setImageFit] = useState<"cover" | "contain">("contain"); @@ -144,6 +151,17 @@ export const GalleryModal = React.memo( const [pageIndex, setPageIndex] = useState(0); const [hasMore, setHasMore] = useState(true); + // Bulk selection state (view mode only) + const [bulkSelectedIds, setBulkSelectedIds] = useState>( + new Set(), + ); + const bulkSelectionMode = bulkSelectedIds.size > 0; + + // Clear bulk selection when filter changes + useEffect(() => { + setBulkSelectedIds(new Set()); + }, [activeFilter]); + // NB: We've got some kind of cursoring issue where subsequent pages are not requested. // For now, let's set the pagination limit really high. const pageSize = 100; @@ -180,11 +198,11 @@ export const GalleryModal = React.memo( Object.entries(grouped).sort( (a, b) => new Date(b[1][0].createdAt).getTime() - - new Date(a[1][0].createdAt).getTime() - ) + new Date(a[1][0].createdAt).getTime(), + ), ); }, - [formatDate] + [formatDate], ); const handleImageError = useCallback( @@ -192,7 +210,7 @@ export const GalleryModal = React.memo( console.error(`Failed to load gallery modal image: ${url}`); failedImageUrls.add(url); }, - [failedImageUrls] + [failedImageUrls], ); useEffect(() => { @@ -280,7 +298,7 @@ export const GalleryModal = React.memo( if (response.success && response.data) { const newItems = response.data .filter( - (item: any) => item.media_type !== FilterMediaType.SCENE_JSON + (item: any) => item.media_type !== FilterMediaType.SCENE_JSON, ) .map((item: any) => ({ id: item.token, @@ -289,10 +307,13 @@ export const GalleryModal = React.memo( item.media_class === "video" ? item.media_links.maybe_video_previews?.animated : item.media_class === "dimensional" - ? item.cover_image?.maybe_cover_image_public_bucket_url - : getThumbnailUrl(item.media_links.maybe_thumbnail_template, { - width: THUMBNAIL_SIZES.MEDIUM, - }), + ? item.cover_image?.maybe_cover_image_public_bucket_url + : getThumbnailUrl( + item.media_links.maybe_thumbnail_template, + { + width: THUMBNAIL_SIZES.MEDIUM, + }, + ), thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template, fullImage: item.media_links.cdn_url, createdAt: item.created_at, @@ -338,8 +359,8 @@ export const GalleryModal = React.memo( mode === "view" ? galleryModalVisibleViewMode.value : typeof isOpen === "boolean" - ? isOpen - : true; + ? isOpen + : true; if (modalIsOpen && username) { refreshGallery(); } @@ -356,13 +377,15 @@ export const GalleryModal = React.memo( (item: GalleryItem) => { if (mode === "select" && onSelectItem) { onSelectItem(item.id); + } else if (bulkSelectionMode) { + toggleBulkSelect(item.id); } else { lightboxImageSignal.value = item; lightboxVisibleSignal.value = true; galleryModalLightboxMediaId.value = item.id; } }, - [mode, onSelectItem] + [mode, onSelectItem, bulkSelectionMode], ); const handleCloseLightbox = useCallback(() => { @@ -384,15 +407,70 @@ export const GalleryModal = React.memo( const handleItemDeleted = useCallback((id: string) => { setAllItems((prev) => prev.filter((it) => it.id !== id)); + setBulkSelectedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); }, []); + const toggleBulkSelect = useCallback((id: string) => { + setBulkSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const clearBulkSelection = useCallback(() => { + setBulkSelectedIds(new Set()); + }, []); + + const bulkSelectedItems = useMemo( + () => allItems.filter((item) => bulkSelectedIds.has(item.id)), + [allItems, bulkSelectedIds], + ); + + const handleBulkDelete = useCallback(() => { + const count = bulkSelectedIds.size; + showActionReminder({ + reminderType: "default", + title: `Delete ${count} item${count > 1 ? "s" : ""}?`, + message: ( +

+ This will permanently remove {count} item{count > 1 ? "s" : ""} from + your library. This action cannot be undone. +

+ ), + primaryActionText: "Delete", + secondaryActionText: "Cancel", + primaryActionBtnClassName: "bg-red text-white hover:bg-red/90", + onPrimaryAction: async () => { + try { + const ids = Array.from(bulkSelectedIds); + await Promise.allSettled(ids.map((id) => MediaFileDelete(id))); + setAllItems((prev) => + prev.filter((it) => !bulkSelectedIds.has(it.id)), + ); + clearBulkSelection(); + } finally { + isActionReminderOpen.value = false; + } + }, + }); + }, [bulkSelectedIds, clearBulkSelection]); + useSignals(); // Synchronize lightbox image when external media id signal is set useEffect(() => { if (galleryModalLightboxMediaId.value && allItems.length > 0) { const target = allItems.find( - (it) => it.id === galleryModalLightboxMediaId.value + (it) => it.id === galleryModalLightboxMediaId.value, ); if (target) { lightboxImageSignal.value = target; @@ -425,11 +503,12 @@ export const GalleryModal = React.memo( ? galleryModalVisibleViewMode.value && galleryModalVisibleDuringDrag.value : typeof isOpen === "boolean" - ? isOpen - : true + ? isOpen + : true } onClose={() => { if (mode === "view") { + clearBulkSelection(); onClose?.() || (galleryModalVisibleViewMode.value = false); } else { onClose?.(); @@ -438,7 +517,7 @@ export const GalleryModal = React.memo( className={twMerge( "h-[620px] max-w-4xl rounded-xl", mode === "view" && - "h-[640px] min-h-[640px] min-w-[56rem] w-[56rem] max-w-none" + "h-[640px] min-h-[640px] min-w-[56rem] w-[56rem] max-w-none", )} childPadding={false} showClose={false} @@ -462,18 +541,18 @@ export const GalleryModal = React.memo( ? "Select Video" : "Select Videos" : activeFilter === "3d" - ? maxSelections === 1 - ? "Select 3D Object" - : "Select 3D Objects" - : activeFilter === "uploaded" - ? maxSelections === 1 - ? "Select Upload" - : "Select Uploads" - : activeFilter === "all" - ? "Select Media" - : maxSelections === 1 - ? "Select Image" - : "Select Images" + ? maxSelections === 1 + ? "Select 3D Object" + : "Select 3D Objects" + : activeFilter === "uploaded" + ? maxSelections === 1 + ? "Select Upload" + : "Select Uploads" + : activeFilter === "all" + ? "Select Media" + : maxSelections === 1 + ? "Select Image" + : "Select Images" : "My Library"} {mode === "view" && ( @@ -527,7 +606,7 @@ export const GalleryModal = React.memo( variant="action" onClick={() => setImageFit((fit) => - fit === "cover" ? "contain" : "cover" + fit === "cover" ? "contain" : "cover", ) } className="relative z-[51] h-9 w-9 bg-ui-controls/60 hover:bg-ui-controls/90" @@ -587,7 +666,7 @@ export const GalleryModal = React.memo( // Only allow filter changes if no forceFilter was provided if (!forceFilterRef.current) { const filter = FILTERS.find( - (f) => f.label === item.label + (f) => f.label === item.label, ); if (filter) setActiveFilter(filter.id); } @@ -604,6 +683,7 @@ export const GalleryModal = React.memo( { if (mode === "view") { + clearBulkSelection(); galleryModalVisibleViewMode.value = false; } else { onClose?.(); @@ -674,7 +754,9 @@ export const GalleryModal = React.memo( PLACEHOLDER_IMAGES.DEFAULT; e.currentTarget.style.opacity = "0.3"; // Set the `data-brokenurl` property for debugging the broken images: - (e.currentTarget as HTMLImageElement).dataset.brokenurl = item.thumbnail || ""; + ( + e.currentTarget as HTMLImageElement + ).dataset.brokenurl = item.thumbnail || ""; handleImageError(item.thumbnail!); }} disableTooltipAndBadge={mode === "select"} @@ -682,12 +764,17 @@ export const GalleryModal = React.memo( onDeleted={handleItemDeleted} onEditClicked={onEditClicked} maxSelections={maxSelections} + bulkSelected={bulkSelectedIds.has(item.id)} + onBulkSelectToggle={() => + toggleBulkSelect(item.id) + } + bulkSelectionMode={bulkSelectionMode} /> ))}
); - } + }, )} {loading && allItems.length > 0 && (
@@ -703,6 +790,52 @@ export const GalleryModal = React.memo( )}
+ {mode === "view" && bulkSelectionMode && ( +
+
+ {/* Thumbnail previews of selected items */} +
+ {bulkSelectedItems.slice(0, 4).map((si) => ( +
+ {si.thumbnail ? ( + + ) : ( +
+ )} +
+ ))} +
+ + {bulkSelectedIds.size} selected + +
+
+ + +
+
+ )} + {mode === "select" && (
@@ -761,7 +894,7 @@ export const GalleryModal = React.memo( )} ); - } + }, ); GalleryModal.displayName = "GalleryModal"; diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx index 1b03c0eee9..b3d33003e7 100644 --- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx +++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx @@ -6,6 +6,7 @@ import { faDownToLine, faGlobe, faPencil, + faTrashCan, faVideo, faWandMagicSparkles, } from "@fortawesome/pro-solid-svg-icons"; @@ -864,6 +865,7 @@ export function LightboxModal({ {selectedMediaToken && onDeleteClicked && (