import { PermissionAction } from '@supabase/shared-types/out/constants' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { compact, get, sum, uniqBy } from 'lodash' import { Upload } from 'lucide-react' import { DragEventHandler, useCallback, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Checkbox, cn } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS, } from '../Storage.constants' import type { StorageItem } from '../Storage.types' import { formatFolderItems } from '../StorageExplorer/StorageExplorer.utils' import { useStoragePreference } from '../StorageExplorer/useStoragePreference' import { uploadFilesToBucket } from './BucketFilePickerDialog.utils' import { BucketFilePickerRow } from './BucketFilePickerRow' import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState' import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { bucketObjectsInfiniteQueryOptions } from '@/data/storage/bucket-objects-infinite-query' import { storageKeys } from '@/data/storage/keys' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { formatBytes } from '@/lib/helpers' import { noop } from '@/lib/void' const SelectAllCheckbox = ({ columnFiles, selectedFilesFromColumn, onChange, }: { columnFiles: StorageItem[] selectedFilesFromColumn: StorageItem[] onChange: () => void }) => ( ) const DragOverOverlay = ({ isOpen, onDragLeave, onDrop, folderIsEmpty, }: { isOpen: boolean onDragLeave: () => void onDrop: () => void folderIsEmpty: boolean }) => { return ( {isOpen && (
{!folderIsEmpty && (

Drop your files to upload to this folder

)}
)}
) } export interface BucketFilePickerColumnProps { index: number fullWidth?: boolean } export const BucketFilePickerColumn = ({ index, fullWidth = false, }: BucketFilePickerColumnProps) => { const { ref: projectRef } = useParams() const queryClient = useQueryClient() const [isDraggedOver, setIsDraggedOver] = useState(false) const columnRef = useRef(null) const { hostEndpoint } = useProjectApiUrl({ projectRef: projectRef! }) const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const { columns, itemSearchString, bucket, maxFiles, acceptedFileExtensions, pushColumnAtIndex, selectedItems, setSelectedItems, clearSelectedItems, selectedFilePreview, setSelectedFilePreview, popColumnAtIndex, } = useBucketFilePickerStateSnapshot() const isFileAccepted = (fileName: string) => { if (!acceptedFileExtensions || acceptedFileExtensions.length === 0) return true const ext = fileName.split('.').pop()?.toLowerCase() ?? '' return acceptedFileExtensions.map((e) => e.replace(/^\./, '').toLowerCase()).includes(ext) } const path = columns.slice(0, index).join('/') const selectedFolder = columns[index] const setSelectedFolder = (folderName: string | null) => { if (folderName) { pushColumnAtIndex(folderName, index) } } const isLastFolder = index === columns.length const { view, sortBy, sortByOrder } = useStoragePreference(projectRef!) // v2's sortBy.column doesn't support 'last_accessed_at'; the picker's own sort dropdown no // longer offers it, but the preference is shared with the (still v1) main file explorer. const sortColumn = sortBy === STORAGE_SORT_BY.LAST_ACCESSED_AT ? STORAGE_SORT_BY.NAME : sortBy const debouncedSearchString = useDebounce(itemSearchString, 500) const { data, isLoading, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ ...bucketObjectsInfiniteQueryOptions({ projectRef, bucketId: bucket.id, path, options: { sortBy: { column: sortColumn, order: sortByOrder, }, // When a user tries to search, only search in the last opened folder (rightmost column) ...(isLastFolder && debouncedSearchString ? { search: debouncedSearchString } : {}), }, }), }) const items = useMemo(() => { const pages = data?.pages ?? [] // v2's `name` is the full key/prefix from the bucket root (folders end with a trailing // slash), not the bare file/folder name — take the last path segment for display. const objs = pages .flatMap((page) => page.objects) .map((object) => ({ ...object, name: object.name.split('/').pop() ?? object.name })) // v2 returns folders and objects separately; re-combine them into the single sorted list // formatFolderItems expects, tagging folders with `id: null` per its existing convention. const folders = pages .flatMap((page) => page.folders) .map((folder) => { const folderPath = folder.name.replace(/\/$/, '') return { id: null, name: folderPath.split('/').pop() ?? folderPath, created_at: null, updated_at: null, last_accessed_at: null, metadata: null, } }) const direction = sortByOrder === STORAGE_SORT_BY_ORDER.DESC ? -1 : 1 const merged = [...folders, ...objs].sort((a, b) => { const aValue = (sortColumn === STORAGE_SORT_BY.NAME ? a.name : a[sortColumn]) ?? '' const bValue = (sortColumn === STORAGE_SORT_BY.NAME ? b.name : b[sortColumn]) ?? '' return direction * String(aValue).localeCompare(String(bValue)) }) return formatFolderItems(merged) }, [data, sortColumn, sortByOrder]) const haveSelectedItems = selectedItems.length > 0 const columnItemsId = items.map((item) => item.id) const columnFiles = items.filter((item) => item.type === STORAGE_ROW_TYPES.FILE) const selectedItemsFromColumn = selectedItems.filter((item) => columnItemsId.includes(item.id)) const selectedFilesFromColumn = selectedItemsFromColumn.filter( (item) => item.type === STORAGE_ROW_TYPES.FILE ) const columnItems = items.map((item) => ({ ...item, columnIndex: index })) const columnItemsSize = sum(columnItems.map((item) => get(item, ['metadata', 'size'], 0))) const isEmpty = items.filter((item) => item.status !== STORAGE_ROW_STATUS.LOADING).length === 0 const getItemKey = useCallback( (index: number) => { const item = columnItems[index] return item?.id || `file-explorer-item-${index}` }, [columnItems] ) const itemProps = useMemo( () => ({ view: view, columnIndex: index, selectedItems, hideCheckbox: maxFiles === 1, }), [view, index, selectedItems, maxFiles] ) const onSelectAllItemsInColumn = () => { const columnFiles = columnItems.filter((item) => item.type === STORAGE_ROW_TYPES.FILE) const columnFilesId = compact(columnFiles.map((item) => item.id)) const selectedItemsFromColumn = selectedItems.filter( (item) => item.id && columnFilesId.includes(item.id) ) if (selectedItemsFromColumn.length === columnFiles.length) { // Deselect all items from column const updatedSelectedItems = selectedItems.filter( (item) => item.id && !columnFilesId.includes(item.id) ) setSelectedItems(updatedSelectedItems) } else { // Select all items from column const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id') setSelectedItems(updatedSelectedItems) } } const onSelectColumnEmptySpace = (columnIndex: number) => { popColumnAtIndex(columnIndex) setSelectedFilePreview(undefined) clearSelectedItems() } const onDragOver: DragEventHandler = (event) => { if (event) { event.stopPropagation() event.preventDefault() if (event.type === 'dragover' && !isDraggedOver) { setIsDraggedOver(true) } } } const onDrop: DragEventHandler = async (event) => { onDragOver(event) if (!canUpdateStorage) { toast('You need additional permissions to upload files to this project') return } if (!hostEndpoint) { toast.error('Unable to upload files at this time. Please try again.') return } const files = Array.from(event.dataTransfer?.files ?? []) as File[] await uploadFilesToBucket({ files, projectRef: projectRef!, hostEndpoint: hostEndpoint, bucketName: bucket.name, bucketId: bucket.id, currentPath: columns.slice(0, index).join('/'), queryClient, }) queryClient.invalidateQueries({ queryKey: storageKeys.objects(projectRef!, bucket.id, columns.slice(0, index).join('/')), }) setIsDraggedOver(false) } return ( <>
{ const eventTarget = get(event.target, ['className'], '') if (typeof eventTarget === 'string' && eventTarget.includes('react-contexify')) return onSelectColumnEmptySpace(index) }} > {/* Checkbox selection for select all */} {view === STORAGE_VIEWS.COLUMNS && maxFiles !== 1 && (
event.stopPropagation()} > {columnFiles.length > 0 ? ( <> onSelectAllItemsInColumn()} />

Select all {columnFiles.length} files

) : (

No files available for selection

)}
)} {/* List Interface Header */} {view === STORAGE_VIEWS.LIST && (
{maxFiles !== 1 && ( onSelectAllItemsInColumn()} /> )}

Name

Size

Type

Created at

Last modified at

)} {/* Shimmering loaders while fetching contents */} {isLoading && (
)} {/* Column Interface */} {columnItems.length > 0 && ( (index !== 0 && index === columnItems.length ? 85 : 37)} // eslint-disable-next-line react/no-unstable-nested-components ItemComponent={(props) => { const item = props.item const isPreviewed = !!( selectedFilePreview?.id !== null && selectedFilePreview?.id === item.id ) const isOpened = selectedFolder !== null && item.type === STORAGE_ROW_TYPES.FOLDER && selectedFolder === item.name return ( i.id === item.id)} hideCheckbox={maxFiles === 1} isDisabled={ item.type === STORAGE_ROW_TYPES.FILE && !isFileAccepted(item.name ?? '') } onClick={(event) => { event.stopPropagation() event.preventDefault() if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) { if (item.type === STORAGE_ROW_TYPES.FOLDER) { setSelectedFilePreview(undefined) setSelectedFolder(item.name) } else { setSelectedFilePreview(item) // deselect all folders when previewing a file popColumnAtIndex(index) clearSelectedItems() } } }} /> ) }} LoaderComponent={LoaderForIconMenuItems} hasNextPage={hasNextPage} isLoadingNextPage={isFetching} onLoadNextPage={fetchNextPage} /> )} {debouncedSearchString.length > 0 && isEmpty && !isLoading && (

No results found in this folder

Your search for "{debouncedSearchString}" did not return any results

)} {debouncedSearchString.length === 0 && isEmpty && !isLoading && (

Drop your files here

Or upload them via the "Upload files" button above

)} setIsDraggedOver(false)} onDrop={() => setIsDraggedOver(false)} /> {/* List interface footer */} {view === STORAGE_VIEWS.LIST && (

{formatBytes(columnItemsSize)} for {columnItems.length} items

)}
{selectedFolder ? ( ) : null} ) }