BucketFilePickerColumn.tsx432 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { useQueryClient } from '@tanstack/react-query' |
| 3 | import { useDebounce } from '@uidotdev/usehooks' |
| 4 | import { useParams } from 'common' |
| 5 | import { AnimatePresence, motion } from 'framer-motion' |
| 6 | import { compact, get, sum, uniqBy } from 'lodash' |
| 7 | import { Upload } from 'lucide-react' |
| 8 | import { DragEventHandler, useCallback, useMemo, useRef, useState } from 'react' |
| 9 | import { toast } from 'sonner' |
| 10 | import { Checkbox, cn } from 'ui' |
| 11 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 12 | |
| 13 | import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants' |
| 14 | import type { StorageItem } from '../Storage.types' |
| 15 | import { formatFolderItems } from '../StorageExplorer/StorageExplorer.utils' |
| 16 | import { useStoragePreference } from '../StorageExplorer/useStoragePreference' |
| 17 | import { uploadFilesToBucket } from './BucketFilePickerDialog.utils' |
| 18 | import { BucketFilePickerRow } from './BucketFilePickerRow' |
| 19 | import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState' |
| 20 | import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList' |
| 21 | import { useProjectApiUrl } from '@/data/config/project-endpoint-query' |
| 22 | import { useBucketObjectsInfiniteQuery } from '@/data/storage/bucket-objects-infinite-query' |
| 23 | import { storageKeys } from '@/data/storage/keys' |
| 24 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 25 | import { formatBytes } from '@/lib/helpers' |
| 26 | import { noop } from '@/lib/void' |
| 27 | |
| 28 | const 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 | |
| 45 | const 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 | |
| 90 | export interface BucketFilePickerColumnProps { |
| 91 | index: number |
| 92 | fullWidth?: boolean |
| 93 | } |
| 94 | |
| 95 | export 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 | } |