diff --git a/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx b/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx index b5d9f99da00..b0c5383c81f 100644 --- a/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx +++ b/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx @@ -36,6 +36,7 @@ export function FileSidebar() { isInitializing, removeFile, setActiveIndex, + thumbnailUrls, totalErrorCount, } = useFormsManager() const { initialFiles, maxFiles } = useBulkUpload() @@ -148,9 +149,7 @@ export function FileSidebar() { >

diff --git a/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx b/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx index 362ac23c877..2f6aeae50c8 100644 --- a/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx +++ b/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx @@ -16,6 +16,7 @@ import { useTranslation } from '../../../providers/Translation/index.js' import { getFormState } from '../../../utilities/getFormState.js' import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js' import { useLoadingOverlay } from '../../LoadingOverlay/index.js' +import { createThumbnail } from '../../Thumbnail/createThumbnail.js' import { useBulkUpload } from '../index.js' import { createFormData } from './createFormData.js' import { formsManagementReducer } from './reducer.js' @@ -41,6 +42,7 @@ type FormsManagerContext = { errorCount: number index: number }) => void + readonly thumbnailUrls: string[] readonly totalErrorCount?: number } @@ -59,6 +61,7 @@ const Context = React.createContext({ saveAllDocs: () => Promise.resolve(), setActiveIndex: () => 0, setFormTotalErrorCount: () => {}, + thumbnailUrls: [], totalErrorCount: 0, }) @@ -90,6 +93,40 @@ export function FormsManagerProvider({ children }: FormsManagerProps) { const [state, dispatch] = React.useReducer(formsManagementReducer, initialState) const { activeIndex, forms, totalErrorCount } = state + const formsRef = React.useRef(forms) + formsRef.current = forms + const formsCount = forms.length + + const thumbnailUrlsRef = React.useRef([]) + const processedFiles = React.useRef(new Set()) // Track already-processed files + const [renderedThumbnails, setRenderedThumbnails] = React.useState([]) + + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + ;(async () => { + const newThumbnails = [...thumbnailUrlsRef.current] + + for (let i = 0; i < formsCount; i++) { + const file = formsRef.current[i].formState.file.value as File + + // Skip if already processed + if (processedFiles.current.has(file) || !file) { + continue + } + processedFiles.current.add(file) + + // Generate thumbnail and update ref + const thumbnailUrl = await createThumbnail(file) + newThumbnails[i] = thumbnailUrl + thumbnailUrlsRef.current = newThumbnails + + // Trigger re-render in batches + setRenderedThumbnails([...newThumbnails]) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + })() + }, [formsCount, createThumbnail]) + const { toggleLoadingOverlay } = useLoadingOverlay() const { closeModal } = useModal() const { collectionSlug, drawerSlug, initialFiles, onSuccess } = useBulkUpload() @@ -378,6 +415,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) { saveAllDocs, setActiveIndex, setFormTotalErrorCount, + thumbnailUrls: renderedThumbnails, totalErrorCount, }} > diff --git a/packages/ui/src/elements/Thumbnail/createThumbnail.ts b/packages/ui/src/elements/Thumbnail/createThumbnail.ts new file mode 100644 index 00000000000..b5dfc859716 --- /dev/null +++ b/packages/ui/src/elements/Thumbnail/createThumbnail.ts @@ -0,0 +1,52 @@ +/** + * Create a thumbnail from a File object by drawing it onto an OffscreenCanvas + */ +export const createThumbnail = (file: File): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.src = URL.createObjectURL(file) // Use Object URL directly + + img.onload = () => { + const maxDimension = 280 + let drawHeight: number, drawWidth: number + + // Calculate aspect ratio + const aspectRatio = img.width / img.height + + // Determine dimensions to fit within maxDimension while maintaining aspect ratio + if (aspectRatio > 1) { + // Image is wider than tall + drawWidth = maxDimension + drawHeight = maxDimension / aspectRatio + } else { + // Image is taller than wide, or square + drawWidth = maxDimension * aspectRatio + drawHeight = maxDimension + } + + const canvas = new OffscreenCanvas(drawWidth, drawHeight) // Create an OffscreenCanvas + const ctx = canvas.getContext('2d') + + // Draw the image onto the OffscreenCanvas with calculated dimensions + ctx.drawImage(img, 0, 0, drawWidth, drawHeight) + + // Convert the OffscreenCanvas to a Blob and free up memory + canvas + .convertToBlob({ type: 'image/jpeg', quality: 0.25 }) + .then((blob) => { + URL.revokeObjectURL(img.src) // Release the Object URL + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) // Resolve as data URL + reader.onerror = reject + reader.readAsDataURL(blob) + }) + .catch(reject) + } + + img.onerror = (error) => { + URL.revokeObjectURL(img.src) // Release Object URL on error + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(error) + } + }) +} diff --git a/packages/ui/src/elements/Thumbnail/index.tsx b/packages/ui/src/elements/Thumbnail/index.tsx index 7e7cb651be9..2a6ba88089b 100644 --- a/packages/ui/src/elements/Thumbnail/index.tsx +++ b/packages/ui/src/elements/Thumbnail/index.tsx @@ -8,6 +8,7 @@ const baseClass = 'thumbnail' import type { SanitizedCollectionConfig } from 'payload' import { File } from '../../graphics/File/index.js' +import { useIntersect } from '../../hooks/useIntersect.js' import { ShimmerEffect } from '../ShimmerEffect/index.js' export type ThumbnailProps = { @@ -28,6 +29,7 @@ export const Thumbnail: React.FC = (props) => { React.useEffect(() => { if (!fileSrc) { + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setFileExists(false) return } @@ -72,6 +74,7 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) { React.useEffect(() => { if (!fileSrc) { + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setFileExists(false) return }