BucketFilePickerHeader.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useQueryClient } from '@tanstack/react-query'
  3. import { useParams } from 'common'
  4. import {
  5. ArrowLeft,
  6. Check,
  7. ChevronRight,
  8. Columns,
  9. List,
  10. RefreshCw,
  11. Search,
  12. Upload,
  13. X,
  14. } from 'lucide-react'
  15. import { useRef, useState, type ChangeEvent } from 'react'
  16. import {
  17. Button,
  18. DropdownMenu,
  19. DropdownMenuContent,
  20. DropdownMenuItem,
  21. DropdownMenuSeparator,
  22. DropdownMenuSub,
  23. DropdownMenuSubContent,
  24. DropdownMenuSubTrigger,
  25. DropdownMenuTrigger,
  26. } from 'ui'
  27. import { Input } from 'ui-patterns/DataInputs/Input'
  28. import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants'
  29. import { useStoragePreference } from '../StorageExplorer/useStoragePreference'
  30. import { uploadFilesToBucket } from './BucketFilePickerDialog.utils'
  31. import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState'
  32. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  33. import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
  34. import { storageKeys } from '@/data/storage/keys'
  35. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  36. const VIEW_OPTIONS = [
  37. { key: STORAGE_VIEWS.COLUMNS, name: 'As columns' },
  38. { key: STORAGE_VIEWS.LIST, name: 'As list' },
  39. ]
  40. const SORT_BY_OPTIONS = [
  41. { key: STORAGE_SORT_BY.NAME, name: 'Name' },
  42. { key: STORAGE_SORT_BY.CREATED_AT, name: 'Time created' },
  43. { key: STORAGE_SORT_BY.UPDATED_AT, name: 'Time modified' },
  44. { key: STORAGE_SORT_BY.LAST_ACCESSED_AT, name: 'Time last accessed' },
  45. ]
  46. const SORT_ORDER_OPTIONS = [
  47. { key: STORAGE_SORT_BY_ORDER.ASC, name: 'Ascending' },
  48. { key: STORAGE_SORT_BY_ORDER.DESC, name: 'Descending' },
  49. ]
  50. const HeaderBreadcrumbs = ({
  51. breadcrumbs,
  52. selectBreadcrumb,
  53. }: {
  54. breadcrumbs: string[]
  55. selectBreadcrumb: (i: number) => void
  56. }) => {
  57. // Max 5 crumbs, otherwise replace middle segment with ellipsis and only
  58. // have the first 2 and last 2 crumbs visible
  59. const ellipsis = '...'
  60. const breadcrumbsWithIndexes = breadcrumbs.map((name: string, index: number) => {
  61. return { name, index }
  62. })
  63. const formattedBreadcrumbs =
  64. breadcrumbsWithIndexes.length <= 5
  65. ? breadcrumbsWithIndexes
  66. : breadcrumbsWithIndexes
  67. .slice(0, 2)
  68. .concat([{ name: ellipsis, index: -1 }])
  69. .concat(
  70. breadcrumbsWithIndexes.slice(
  71. breadcrumbsWithIndexes.length - 2,
  72. breadcrumbsWithIndexes.length
  73. )
  74. )
  75. return (
  76. <div className="ml-3 flex min-w-0 flex-1 items-center overflow-hidden">
  77. {formattedBreadcrumbs.map((crumb, idx: number) => {
  78. const isEllipsis = crumb.name === ellipsis
  79. const isActive = crumb.index === breadcrumbs.length - 1
  80. return (
  81. <div className="flex shrink-0 items-center" key={`${crumb.index}-${crumb.name}`}>
  82. {idx !== 0 && (
  83. <ChevronRight size={14} strokeWidth={2} className="text-foreground-muted mx-1" />
  84. )}
  85. {isEllipsis ? (
  86. <span className="max-w-24 truncate text-sm text-foreground-light">{crumb.name}</span>
  87. ) : isActive ? (
  88. <span className="max-w-24 truncate text-sm text-foreground">{crumb.name}</span>
  89. ) : (
  90. <button
  91. type="button"
  92. className="max-w-24 truncate border-0 bg-transparent p-0 text-left text-sm text-foreground-lighter transition-colors hover:text-foreground focus-visible:text-foreground"
  93. onClick={() => selectBreadcrumb(crumb.index)}
  94. >
  95. {crumb.name}
  96. </button>
  97. )}
  98. </div>
  99. )
  100. })}
  101. </div>
  102. )
  103. }
  104. export const BucketFilePickerHeader = () => {
  105. const { ref: projectRef } = useParams()
  106. const queryClient = useQueryClient()
  107. const [isSearching, setIsSearching] = useState(false)
  108. const [isRefreshing, setIsRefreshing] = useState(false)
  109. const [isUploading, setIsUploading] = useState(false)
  110. const uploadButtonRef = useRef<HTMLInputElement | null>(null)
  111. const { hostEndpoint } = useProjectApiUrl({ projectRef: projectRef! })
  112. const { view, sortBy, sortByOrder, setSortBy, setSortByOrder, setView } = useStoragePreference(
  113. projectRef!
  114. )
  115. const {
  116. bucket,
  117. columns,
  118. itemSearchString,
  119. setItemSearchString,
  120. popColumn,
  121. popColumnAtIndex,
  122. setSelectedFilePreview,
  123. } = useBucketFilePickerStateSnapshot()
  124. const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
  125. const breadcrumbs = columns
  126. const backDisabled = columns.length < 1
  127. const onSelectBack = () => {
  128. popColumn()
  129. setSelectedFilePreview(undefined)
  130. }
  131. const onSelectUpload = () => {
  132. if (uploadButtonRef.current) {
  133. uploadButtonRef.current.click()
  134. }
  135. }
  136. const handleFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
  137. if (!hostEndpoint) {
  138. console.error('Host endpoint not available')
  139. return
  140. }
  141. const files = Array.from(event.target.files || [])
  142. try {
  143. setIsUploading(true)
  144. await uploadFilesToBucket({
  145. files,
  146. projectRef: projectRef!,
  147. hostEndpoint,
  148. bucketName: bucket.name,
  149. bucketId: bucket.id,
  150. currentPath: columns.join('/'),
  151. queryClient,
  152. })
  153. queryClient.invalidateQueries({
  154. queryKey: storageKeys.objects(projectRef!, bucket.id, columns.join('/')),
  155. })
  156. } catch (error) {
  157. console.error('Failed to upload files:', error)
  158. // Consider showing a toast notification to the user
  159. } finally {
  160. event.target.value = ''
  161. setIsUploading(false)
  162. }
  163. }
  164. /** Methods for searching */
  165. const toggleSearch = () => {
  166. setIsSearching(true)
  167. }
  168. const onCancelSearch = () => {
  169. setIsSearching(false)
  170. setItemSearchString('')
  171. }
  172. /** Methods for breadcrumbs */
  173. const selectBreadcrumb = (columnIndex: number) => {
  174. popColumnAtIndex(columnIndex)
  175. }
  176. const refreshData = async () => {
  177. setIsRefreshing(true)
  178. const queryKey = storageKeys.objects(projectRef!, bucket.id, '').filter(Boolean)
  179. try {
  180. await queryClient.refetchQueries({ queryKey: queryKey, type: 'active' })
  181. } finally {
  182. setIsRefreshing(false)
  183. }
  184. }
  185. return (
  186. <div className="rounded-t-md border-b border-overlay bg-surface-100">
  187. <div className="overflow-x-auto overflow-y-hidden">
  188. <div className="flex min-h-[40px] w-max min-w-full items-center justify-between">
  189. {/* Navigation */}
  190. <div className="flex min-w-0 flex-1 items-center overflow-hidden pl-2 py-[7px]">
  191. {breadcrumbs.length > 0 && (
  192. <>
  193. <Button
  194. icon={<ArrowLeft size={16} strokeWidth={2} />}
  195. size="tiny"
  196. type="text"
  197. className="shrink-0 px-1"
  198. disabled={backDisabled}
  199. onClick={() => {
  200. onSelectBack()
  201. }}
  202. />
  203. <div className="mx-1 h-5 shrink-0 border-r border-strong" />
  204. </>
  205. )}
  206. {breadcrumbs.length > 0 ? (
  207. <HeaderBreadcrumbs breadcrumbs={breadcrumbs} selectBreadcrumb={selectBreadcrumb} />
  208. ) : null}
  209. </div>
  210. {/* Actions */}
  211. <div className="flex shrink-0 items-center whitespace-nowrap py-[7px]">
  212. <div className="flex shrink-0 items-center space-x-1 px-2">
  213. <Button
  214. size="tiny"
  215. icon={<RefreshCw />}
  216. type="text"
  217. loading={isRefreshing}
  218. onClick={refreshData}
  219. >
  220. Reload
  221. </Button>
  222. <DropdownMenu>
  223. <DropdownMenuTrigger asChild>
  224. <Button
  225. type="text"
  226. icon={
  227. view === 'LIST' ? (
  228. <List size={16} strokeWidth={2} />
  229. ) : (
  230. <Columns size={16} strokeWidth={2} />
  231. )
  232. }
  233. >
  234. View
  235. </Button>
  236. </DropdownMenuTrigger>
  237. <DropdownMenuContent align="end" className="w-40 min-w-0">
  238. {VIEW_OPTIONS.map((option) => (
  239. <DropdownMenuItem key={option.key} onClick={() => setView(option.key)}>
  240. <div className="flex items-center justify-between w-full">
  241. <p>{option.name}</p>
  242. {view === option.key && (
  243. <Check size={16} className="text-brand" strokeWidth={2} />
  244. )}
  245. </div>
  246. </DropdownMenuItem>
  247. ))}
  248. <DropdownMenuSeparator />
  249. <DropdownMenuSub>
  250. <DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
  251. <DropdownMenuSubContent className="w-44">
  252. {SORT_BY_OPTIONS.map((option) => (
  253. <DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
  254. <div className="flex items-center justify-between w-full">
  255. <p>{option.name}</p>
  256. {sortBy === option.key && (
  257. <Check size={16} className="text-brand" strokeWidth={2} />
  258. )}
  259. </div>
  260. </DropdownMenuItem>
  261. ))}
  262. </DropdownMenuSubContent>
  263. </DropdownMenuSub>
  264. <DropdownMenuSub>
  265. <DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
  266. <DropdownMenuSubContent>
  267. {SORT_ORDER_OPTIONS.map((option) => (
  268. <DropdownMenuItem
  269. key={option.key}
  270. onClick={() => setSortByOrder(option.key)}
  271. >
  272. <div className="flex items-center justify-between w-full">
  273. <p>{option.name}</p>
  274. {sortByOrder === option.key && (
  275. <Check size={16} className="text-brand" strokeWidth={2} />
  276. )}
  277. </div>
  278. </DropdownMenuItem>
  279. ))}
  280. </DropdownMenuSubContent>
  281. </DropdownMenuSub>
  282. </DropdownMenuContent>
  283. </DropdownMenu>
  284. </div>
  285. <div className="h-6 shrink-0 border-r border-control" />
  286. <div className="flex shrink-0 items-center space-x-1 px-2">
  287. <div className="hidden">
  288. <input ref={uploadButtonRef} type="file" multiple onChange={handleFilesUpload} />
  289. </div>
  290. <ButtonTooltip
  291. icon={<Upload size={16} strokeWidth={2} />}
  292. type="text"
  293. disabled={!canUpdateStorage}
  294. loading={isUploading}
  295. onClick={onSelectUpload}
  296. tooltip={{
  297. content: {
  298. side: 'bottom',
  299. text: !canUpdateStorage
  300. ? 'You need additional permissions to upload files'
  301. : undefined,
  302. },
  303. }}
  304. >
  305. Upload files
  306. </ButtonTooltip>
  307. </div>
  308. <div className="h-6 shrink-0 border-r border-control" />
  309. <div className="flex shrink-0 items-center px-2">
  310. {isSearching ? (
  311. <Input
  312. size="tiny"
  313. autoFocus
  314. className="w-52"
  315. icon={<Search />}
  316. actions={[
  317. <Button
  318. key="cancel"
  319. size="tiny"
  320. type="text"
  321. icon={<X />}
  322. onClick={onCancelSearch}
  323. className="p-0 h-5 w-5"
  324. />,
  325. ]}
  326. placeholder="Search for a file or folder"
  327. type="text"
  328. value={itemSearchString}
  329. onChange={(event) => setItemSearchString(event.target.value)}
  330. />
  331. ) : (
  332. <Button
  333. icon={<Search />}
  334. size="tiny"
  335. type="text"
  336. className="px-1"
  337. onClick={toggleSearch}
  338. />
  339. )}
  340. </div>
  341. </div>
  342. </div>
  343. </div>
  344. </div>
  345. )
  346. }