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
}