BucketFilePickerColumn.tsx432 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useQueryClient } from '@tanstack/react-query'
3import { useDebounce } from '@uidotdev/usehooks'
4import { useParams } from 'common'
5import { AnimatePresence, motion } from 'framer-motion'
6import { compact, get, sum, uniqBy } from 'lodash'
7import { Upload } from 'lucide-react'
8import { DragEventHandler, useCallback, useMemo, useRef, useState } from 'react'
9import { toast } from 'sonner'
10import { Checkbox, cn } from 'ui'
11import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
12
13import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
14import type { StorageItem } from '../Storage.types'
15import { formatFolderItems } from '../StorageExplorer/StorageExplorer.utils'
16import { useStoragePreference } from '../StorageExplorer/useStoragePreference'
17import { uploadFilesToBucket } from './BucketFilePickerDialog.utils'
18import { BucketFilePickerRow } from './BucketFilePickerRow'
19import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState'
20import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
21import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
22import { useBucketObjectsInfiniteQuery } from '@/data/storage/bucket-objects-infinite-query'
23import { storageKeys } from '@/data/storage/keys'
24import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
25import { formatBytes } from '@/lib/helpers'
26import { noop } from '@/lib/void'
27
28const SelectAllCheckbox = ({
29 columnFiles,
30 selectedFilesFromColumn,
31 onChange,
32}: {
33 columnFiles: StorageItem[]
34 selectedFilesFromColumn: StorageItem[]
35 onChange: () => void
36}) => (
37 <Checkbox
38 className="-mt-0.5"
39 checked={columnFiles.length !== 0 && selectedFilesFromColumn.length === columnFiles.length}
40 disabled={columnFiles.length === 0}
41 onChange={onChange}
42 />
43)
44
45const DragOverOverlay = ({
46 isOpen,
47 onDragLeave,
48 onDrop,
49 folderIsEmpty,
50}: {
51 isOpen: boolean
52 onDragLeave: () => void
53 onDrop: () => void
54 folderIsEmpty: boolean
55}) => {
56 return (
57 <AnimatePresence>
58 {isOpen && (
59 <motion.div
60 initial={{ opacity: 0 }}
61 animate={{ opacity: 1 }}
62 exit={{ opacity: 0 }}
63 transition={{ duration: 0.1, ease: 'easeOut' }}
64 className="h-full w-full absolute top-0"
65 >
66 <div
67 onDragLeave={onDragLeave}
68 onDrop={onDrop}
69 className="absolute top-0 flex h-full w-full items-center justify-center"
70 style={{ backgroundColor: folderIsEmpty ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.2)' }}
71 >
72 {!folderIsEmpty && (
73 <div
74 className="w-3/4 h-32 border-2 border-dashed border-muted rounded-md flex flex-col items-center justify-center p-6 pointer-events-none"
75 style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
76 >
77 <Upload className="text-white pointer-events-none" size={20} strokeWidth={2} />
78 <p className="text-center text-sm text-white mt-2 pointer-events-none">
79 Drop your files to upload to this folder
80 </p>
81 </div>
82 )}
83 </div>
84 </motion.div>
85 )}
86 </AnimatePresence>
87 )
88}
89
90export interface BucketFilePickerColumnProps {
91 index: number
92 fullWidth?: boolean
93}
94
95export const BucketFilePickerColumn = ({
96 index,
97 fullWidth = false,
98}: BucketFilePickerColumnProps) => {
99 const { ref: projectRef } = useParams()
100 const queryClient = useQueryClient()
101
102 const [isDraggedOver, setIsDraggedOver] = useState(false)
103 const columnRef = useRef<HTMLDivElement | null>(null)
104
105 const { hostEndpoint } = useProjectApiUrl({ projectRef: projectRef! })
106 const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
107
108 const {
109 columns,
110 itemSearchString,
111 bucket,
112 maxFiles,
113 acceptedFileExtensions,
114 pushColumnAtIndex,
115 selectedItems,
116 setSelectedItems,
117 clearSelectedItems,
118 selectedFilePreview,
119 setSelectedFilePreview,
120 popColumnAtIndex,
121 } = useBucketFilePickerStateSnapshot()
122
123 const isFileAccepted = (fileName: string) => {
124 if (!acceptedFileExtensions || acceptedFileExtensions.length === 0) return true
125 const ext = fileName.split('.').pop()?.toLowerCase() ?? ''
126 return acceptedFileExtensions.map((e) => e.replace(/^\./, '').toLowerCase()).includes(ext)
127 }
128
129 const path = columns.slice(0, index).join('/')
130 const selectedFolder = columns[index]
131 const setSelectedFolder = (folderName: string | null) => {
132 if (folderName) {
133 pushColumnAtIndex(folderName, index)
134 }
135 }
136 const isLastFolder = index === columns.length
137
138 const { view, sortBy, sortByOrder } = useStoragePreference(projectRef!)
139
140 const debouncedSearchString = useDebounce(itemSearchString, 500)
141 const { data, isLoading, isFetching, fetchNextPage, hasNextPage } = useBucketObjectsInfiniteQuery(
142 {
143 projectRef,
144 bucketId: bucket.id,
145 path,
146 options: {
147 sortBy: {
148 column: sortBy,
149 order: sortByOrder,
150 },
151 // When a user tries to search, only search in the last opened folder (rightmost column)
152 ...(isLastFolder && debouncedSearchString ? { search: debouncedSearchString } : {}),
153 },
154 }
155 )
156
157 const items = useMemo(() => {
158 const objs = data?.pages.flatMap((page) => page) || []
159 return formatFolderItems(objs)
160 }, [data])
161
162 const haveSelectedItems = selectedItems.length > 0
163 const columnItemsId = items.map((item) => item.id)
164 const columnFiles = items.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
165 const selectedItemsFromColumn = selectedItems.filter((item) => columnItemsId.includes(item.id))
166 const selectedFilesFromColumn = selectedItemsFromColumn.filter(
167 (item) => item.type === STORAGE_ROW_TYPES.FILE
168 )
169
170 const columnItems = items.map((item) => ({ ...item, columnIndex: index }))
171 const columnItemsSize = sum(columnItems.map((item) => get(item, ['metadata', 'size'], 0)))
172
173 const isEmpty = items.filter((item) => item.status !== STORAGE_ROW_STATUS.LOADING).length === 0
174
175 const getItemKey = useCallback(
176 (index: number) => {
177 const item = columnItems[index]
178 return item?.id || `file-explorer-item-${index}`
179 },
180 [columnItems]
181 )
182
183 const itemProps = useMemo(
184 () => ({
185 view: view,
186 columnIndex: index,
187 selectedItems,
188 hideCheckbox: maxFiles === 1,
189 }),
190 [view, index, selectedItems, maxFiles]
191 )
192
193 const onSelectAllItemsInColumn = () => {
194 const columnFiles = columnItems.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
195
196 const columnFilesId = compact(columnFiles.map((item) => item.id))
197 const selectedItemsFromColumn = selectedItems.filter(
198 (item) => item.id && columnFilesId.includes(item.id)
199 )
200
201 if (selectedItemsFromColumn.length === columnFiles.length) {
202 // Deselect all items from column
203 const updatedSelectedItems = selectedItems.filter(
204 (item) => item.id && !columnFilesId.includes(item.id)
205 )
206 setSelectedItems(updatedSelectedItems)
207 } else {
208 // Select all items from column
209 const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id')
210 setSelectedItems(updatedSelectedItems)
211 }
212 }
213
214 const onSelectColumnEmptySpace = (columnIndex: number) => {
215 popColumnAtIndex(columnIndex)
216 setSelectedFilePreview(undefined)
217 clearSelectedItems()
218 }
219
220 const onDragOver: DragEventHandler<HTMLDivElement> = (event) => {
221 if (event) {
222 event.stopPropagation()
223 event.preventDefault()
224 if (event.type === 'dragover' && !isDraggedOver) {
225 setIsDraggedOver(true)
226 }
227 }
228 }
229
230 const onDrop: DragEventHandler<HTMLDivElement> = async (event) => {
231 onDragOver(event)
232
233 if (!canUpdateStorage) {
234 toast('You need additional permissions to upload files to this project')
235 return
236 }
237 if (!hostEndpoint) {
238 toast.error('Unable to upload files at this time. Please try again.')
239 return
240 }
241
242 const files = Array.from(event.dataTransfer?.files ?? []) as File[]
243 await uploadFilesToBucket({
244 files,
245 projectRef: projectRef!,
246 hostEndpoint: hostEndpoint,
247 bucketName: bucket.name,
248 bucketId: bucket.id,
249 currentPath: columns.slice(0, index).join('/'),
250 queryClient,
251 })
252
253 queryClient.invalidateQueries({
254 queryKey: storageKeys.objects(projectRef!, bucket.id, columns.slice(0, index).join('/')),
255 })
256
257 setIsDraggedOver(false)
258 }
259
260 return (
261 <>
262 <div
263 ref={columnRef}
264 className={cn(
265 fullWidth ? 'w-full' : 'w-64 border-r border-overlay',
266 view === STORAGE_VIEWS.LIST && 'h-full',
267 'hide-scrollbar relative flex shrink-0 flex-col overflow-auto'
268 )}
269 onDragOver={onDragOver}
270 onDrop={onDrop}
271 onClick={(event) => {
272 const eventTarget = get(event.target, ['className'], '')
273 if (typeof eventTarget === 'string' && eventTarget.includes('react-contexify')) return
274 onSelectColumnEmptySpace(index)
275 }}
276 >
277 {/* Checkbox selection for select all */}
278 {view === STORAGE_VIEWS.COLUMNS && maxFiles !== 1 && (
279 <div
280 className={cn(
281 'sticky top-0 z-10 mb-0 flex items-center bg-table-header-light px-2.5 in-data-[theme*=dark]:bg-table-header-dark',
282 haveSelectedItems ? 'h-10 py-3 opacity-100' : 'h-0 py-0 opacity-0',
283 'transition-all duration-200'
284 )}
285 onClick={(event) => event.stopPropagation()}
286 >
287 {columnFiles.length > 0 ? (
288 <>
289 <SelectAllCheckbox
290 columnFiles={columnFiles}
291 selectedFilesFromColumn={selectedFilesFromColumn}
292 onChange={() => onSelectAllItemsInColumn()}
293 />
294 <p className="text-sm text-foreground-light">
295 Select all {columnFiles.length} files
296 </p>
297 </>
298 ) : (
299 <p className="text-sm text-foreground-light">No files available for selection</p>
300 )}
301 </div>
302 )}
303
304 {/* List Interface Header */}
305 {view === STORAGE_VIEWS.LIST && (
306 <div className="sticky top-0 py-2 z-10 flex min-w-min items-center border-b border-overlay bg-surface-100 px-2.5">
307 <div className="flex w-[40%] min-w-[250px] items-center">
308 {maxFiles !== 1 && (
309 <SelectAllCheckbox
310 columnFiles={columnFiles}
311 selectedFilesFromColumn={selectedFilesFromColumn}
312 onChange={() => onSelectAllItemsInColumn()}
313 />
314 )}
315 <p className="text-sm">Name</p>
316 </div>
317 <p className="w-[11%] min-w-[100px] text-sm">Size</p>
318 <p className="w-[14%] min-w-[100px] text-sm">Type</p>
319 <p className="w-[15%] min-w-[160px] text-sm">Created at</p>
320 <p className="w-[15%] min-w-[160px] text-sm">Last modified at</p>
321 </div>
322 )}
323
324 {/* Shimmering loaders while fetching contents */}
325 {isLoading && (
326 <div
327 className={`
328 ${fullWidth ? 'w-full' : 'w-64 border-r border-default'}
329 px-2 py-1 my-1 flex shrink-0 flex-col space-y-2 overflow-auto
330 `}
331 >
332 <ShimmeringLoader />
333 <ShimmeringLoader />
334 <ShimmeringLoader />
335 </div>
336 )}
337
338 {/* Column Interface */}
339 {columnItems.length > 0 && (
340 <InfiniteListDefault
341 className="h-full"
342 items={columnItems}
343 itemProps={itemProps}
344 getItemKey={getItemKey}
345 getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
346 // eslint-disable-next-line react/no-unstable-nested-components
347 ItemComponent={(props) => {
348 const item = props.item
349 const isPreviewed = !!(
350 selectedFilePreview?.id !== null && selectedFilePreview?.id === item.id
351 )
352 const isOpened =
353 selectedFolder !== null &&
354 item.type === STORAGE_ROW_TYPES.FOLDER &&
355 selectedFolder === item.name
356
357 return (
358 <BucketFilePickerRow
359 {...props}
360 onCheck={noop}
361 isPreviewed={isPreviewed}
362 isOpened={isOpened}
363 isSelected={!!props.selectedItems.find((i) => i.id === item.id)}
364 hideCheckbox={maxFiles === 1}
365 isDisabled={
366 item.type === STORAGE_ROW_TYPES.FILE && !isFileAccepted(item.name ?? '')
367 }
368 onClick={(event) => {
369 event.stopPropagation()
370 event.preventDefault()
371 if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) {
372 if (item.type === STORAGE_ROW_TYPES.FOLDER) {
373 setSelectedFilePreview(undefined)
374 setSelectedFolder(item.name)
375 } else {
376 setSelectedFilePreview(item)
377 // deselect all folders when previewing a file
378 popColumnAtIndex(index)
379 clearSelectedItems()
380 }
381 }
382 }}
383 />
384 )
385 }}
386 LoaderComponent={LoaderForIconMenuItems}
387 hasNextPage={hasNextPage}
388 isLoadingNextPage={isFetching}
389 onLoadNextPage={fetchNextPage}
390 />
391 )}
392
393 {debouncedSearchString.length > 0 && isEmpty && !isLoading && (
394 <div className="h-full w-full flex flex-col items-center justify-center">
395 <p className="text-sm my-3 text-foreground">No results found in this folder</p>
396 <p className="w-40 text-center text-sm text-foreground-light">
397 Your search for "{debouncedSearchString}" did not return any results
398 </p>
399 </div>
400 )}
401
402 {debouncedSearchString.length === 0 && isEmpty && !isLoading && (
403 <div className="h-full w-full flex flex-col items-center justify-center">
404 <p className="text-sm my-3 opacity-75">Drop your files here</p>
405 <p className="w-40 text-center text-xs text-foreground-light">
406 Or upload them via the "Upload files" button above
407 </p>
408 </div>
409 )}
410
411 <DragOverOverlay
412 isOpen={isDraggedOver}
413 folderIsEmpty={isEmpty}
414 onDragLeave={() => setIsDraggedOver(false)}
415 onDrop={() => setIsDraggedOver(false)}
416 />
417
418 {/* List interface footer */}
419 {view === STORAGE_VIEWS.LIST && (
420 <div className="shrink-0 rounded-b-md z-10 flex min-w-min items-center bg-panel-footer-light px-2.5 py-2 in-data-[theme*=dark]:bg-panel-footer-dark w-full">
421 <p className="text-sm">
422 {formatBytes(columnItemsSize)} for {columnItems.length} items
423 </p>
424 </div>
425 )}
426 </div>
427 {selectedFolder ? (
428 <BucketFilePickerColumn key={`column-${index + 1}`} index={index + 1} />
429 ) : null}
430 </>
431 )
432}