BucketFilePickerColumn.tsx 15 KB

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