From 1e8e9d5efe18397c9d27a31be8993f2061df1d86 Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Mon, 14 Sep 2026 08:55:07 -0600 Subject: [PATCH] Allow to shift click samples and mass delete rows of samples --- ui/src/components/SampleImageCard.tsx | 17 +++- ui/src/components/SampleImages.tsx | 128 +++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/ui/src/components/SampleImageCard.tsx b/ui/src/components/SampleImageCard.tsx index 4cafdd0..570d123 100644 --- a/ui/src/components/SampleImageCard.tsx +++ b/ui/src/components/SampleImageCard.tsx @@ -9,7 +9,8 @@ interface SampleImageCardProps { children?: ReactNode; className?: string; onDelete?: () => void; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; + selected?: boolean; /** pass your scroll container element (e.g. containerRef.current) */ observerRoot?: Element | null; /** optional: tweak pre-load buffer */ @@ -24,6 +25,7 @@ const SampleImageCard: React.FC = ({ children, className = '', onClick = () => {}, + selected = false, observerRoot = null, rootMargin = '200px 0px', }) => { @@ -116,9 +118,18 @@ const SampleImageCard: React.FC = ({ return (
-
+
diff --git a/ui/src/components/SampleImages.tsx b/ui/src/components/SampleImages.tsx index 1d6be1b..176f84c 100644 --- a/ui/src/components/SampleImages.tsx +++ b/ui/src/components/SampleImages.tsx @@ -1,17 +1,18 @@ -import { useMemo, useState, useRef, useCallback } from 'react'; +import { useMemo, useState, useRef, useCallback, useEffect } from 'react'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import useSampleImages from '@/hooks/useSampleImages'; import SampleImageCard from './SampleImageCard'; import { Job } from '@prisma/client'; import { JobConfig } from '@/types'; import { LuImageOff, LuLoader, LuBan } from 'react-icons/lu'; -import { Button } from '@headlessui/react'; +import { Button, Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'; import { FaDownload } from 'react-icons/fa'; import { apiClient } from '@/utils/api'; import { encodeFilePathForUrl } from '@/utils/basic'; import classNames from 'classnames'; import { FaCaretDown, FaCaretUp } from 'react-icons/fa'; import SampleImageViewer from './SampleImageViewer'; +import { openConfirm } from './ConfirmModal'; interface SampleImagesMenuProps { job?: Job | null; @@ -74,6 +75,12 @@ interface SampleImagesProps { export default function SampleImages({ job }: SampleImagesProps) { const { sampleImages, status, refreshSampleImages } = useSampleImages(job.id, 5000); const [selectedSamplePath, setSelectedSamplePath] = useState(null); + // multi-select for bulk delete: shift-click ranges from the anchor, ctrl/cmd-click toggles + const [selectedSet, setSelectedSet] = useState>(() => new Set()); + const anchorIdxRef = useRef(null); + // selection as it was when the anchor was set; shift-click ranges are rebuilt on top of it, not accumulated + const baseSetRef = useRef>(new Set()); + const [deleteProgress, setDeleteProgress] = useState<{ done: number; total: number } | null>(null); const [scrollParent, setScrollParent] = useState(null); const scrollParentCallback = useCallback((el: HTMLDivElement | null) => setScrollParent(el), []); const virtuosoRef = useRef(null); @@ -97,6 +104,98 @@ export default function SampleImages({ job }: SampleImagesProps) { return out; }, [sampleImages, numSamples]); + const handleCardClick = useCallback( + (sample: string, e: React.MouseEvent) => { + const isRange = e.shiftKey; + const isToggle = e.ctrlKey || e.metaKey; + if (!isRange && !isToggle) { + setSelectedSet(new Set()); + anchorIdxRef.current = null; + baseSetRef.current = new Set(); + setSelectedSamplePath(sample); + return; + } + e.preventDefault(); + const idx = sampleImages.indexOf(sample); + if (idx === -1) return; + setSelectedSet(prev => { + const anchor = anchorIdxRef.current; + if (isRange && anchor !== null && anchor < sampleImages.length) { + const next = new Set(baseSetRef.current); + const [lo, hi] = anchor < idx ? [anchor, idx] : [idx, anchor]; + for (let i = lo; i <= hi; i++) next.add(sampleImages[i]); + return next; + } + const next = new Set(prev); + if (isToggle && next.has(sample)) { + next.delete(sample); + } else { + next.add(sample); + } + anchorIdxRef.current = idx; + baseSetRef.current = new Set(next); + return next; + }); + }, + [sampleImages], + ); + + const deleteSelected = useCallback(() => { + const paths = Array.from(selectedSet); + if (paths.length === 0) return; + openConfirm({ + title: 'Delete Samples', + message: `Are you sure you want to delete ${paths.length} sample${paths.length === 1 ? '' : 's'}? This action cannot be undone.`, + type: 'warning', + confirmText: 'Delete', + onConfirm: async () => { + setDeleteProgress({ done: 0, total: paths.length }); + // bounded concurrency so the progress counter advances and the server isn't flooded + const CONCURRENCY = 4; + let cursor = 0; + let done = 0; + const worker = async () => { + while (cursor < paths.length) { + const imgPath = paths[cursor++]; + try { + await apiClient.post('/api/img/delete', { imgPath }); + } catch (error) { + console.error('Error deleting sample:', imgPath, error); + } + done++; + setDeleteProgress({ done, total: paths.length }); + } + }; + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, paths.length) }, worker)); + setDeleteProgress(null); + setSelectedSet(new Set()); + anchorIdxRef.current = null; + baseSetRef.current = new Set(); + refreshSampleImages(); + }, + }); + }, [selectedSet, refreshSampleImages]); + + // Delete/Backspace deletes the selection, Escape clears it; ignored while the viewer is open or typing in a field + useEffect(() => { + if (selectedSet.size === 0) return; + const onKeyDown = (e: KeyboardEvent) => { + if (selectedSamplePath) return; + const tag = (e.target as HTMLElement | null)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + deleteSelected(); + } else if (e.key === 'Escape') { + setSelectedSet(new Set()); + anchorIdxRef.current = null; + baseSetRef.current = new Set(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [selectedSet.size, selectedSamplePath, deleteSelected]); + const scrollToBottom = () => { virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end' }); }; @@ -201,7 +300,8 @@ export default function SampleImages({ job }: SampleImagesProps) { numSamples={numSamples} sampleImages={sampleImages} alt="Sample Image" - onClick={() => setSelectedSamplePath(sample)} + onClick={e => handleCardClick(sample, e)} + selected={selectedSet.has(sample) || selectedSamplePath === sample} observerRoot={scrollParent} /> ))} @@ -214,6 +314,28 @@ export default function SampleImages({ job }: SampleImagesProps) { /> )}
+ {}} className="relative z-20"> + +
+ + + + Deleting Samples + +

+ {deleteProgress?.done ?? 0} / {deleteProgress?.total ?? 0} +

+
+
0 ? (deleteProgress.done / deleteProgress.total) * 100 : 0}%`, + }} + /> +
+ +
+