index.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { useDebounce } from '@uidotdev/usehooks'
  2. import { useParams } from 'common'
  3. import { ArrowDownNarrowWide, RefreshCw, Search } from 'lucide-react'
  4. import { parseAsBoolean, useQueryState } from 'nuqs'
  5. import { useCallback, useMemo, useRef, useState } from 'react'
  6. import {
  7. Button,
  8. Card,
  9. DropdownMenu,
  10. DropdownMenuContent,
  11. DropdownMenuRadioGroup,
  12. DropdownMenuRadioItem,
  13. DropdownMenuTrigger,
  14. } from 'ui'
  15. import { Admonition } from 'ui-patterns'
  16. import { Input } from 'ui-patterns/DataInputs/Input'
  17. import { PageContainer } from 'ui-patterns/PageContainer'
  18. import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
  19. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  20. import { CreateBucketModal } from '../CreateBucketModal'
  21. import { EmptyBucketState } from '../EmptyBucketState'
  22. import { CreateBucketButton } from '../NewBucketButton'
  23. import { STORAGE_BUCKET_SORT } from '../Storage.constants'
  24. import { useStoragePreference } from '../StorageExplorer/useStoragePreference'
  25. import { BucketsTable } from './BucketsTable'
  26. import { useFilesBucketsShortcuts } from './useFilesBucketsShortcuts'
  27. import AlertError from '@/components/ui/AlertError'
  28. import { InlineLink } from '@/components/ui/InlineLink'
  29. import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
  30. import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
  31. import { usePaginatedBucketsQuery } from '@/data/storage/buckets-query'
  32. import { IS_PLATFORM } from '@/lib/constants'
  33. import { formatBytes } from '@/lib/helpers'
  34. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  35. import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
  36. export const FilesBuckets = () => {
  37. const { ref } = useParams()
  38. const snap = useStorageExplorerStateSnapshot()
  39. const { sortBucket, setSortBucket } = useStoragePreference(snap.projectRef)
  40. const searchInputRef = useRef<HTMLInputElement>(null)
  41. const [filterString, setFilterString] = useState('')
  42. const debouncedFilterString = useDebounce(filterString, 250)
  43. const normalizedSearch = debouncedFilterString.trim()
  44. const sortColumn = sortBucket === STORAGE_BUCKET_SORT.ALPHABETICAL ? 'name' : 'created_at'
  45. const sortOrder = sortBucket === STORAGE_BUCKET_SORT.ALPHABETICAL ? 'asc' : 'desc'
  46. const [visible, setVisible] = useQueryState(
  47. 'new',
  48. parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
  49. )
  50. const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
  51. const {
  52. data: bucketsData,
  53. error: bucketsError,
  54. isError: isErrorBuckets,
  55. isPending: isLoadingBuckets,
  56. isSuccess: isSuccessBuckets,
  57. isFetching: isFetchingBuckets,
  58. fetchNextPage,
  59. hasNextPage,
  60. refetch: refetchBuckets,
  61. } = usePaginatedBucketsQuery({
  62. projectRef: ref,
  63. search: normalizedSearch.length > 0 ? normalizedSearch : undefined,
  64. sortColumn,
  65. sortOrder,
  66. })
  67. const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData])
  68. const fileBuckets = buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD')
  69. const hasNoBuckets = fileBuckets.length === 0 && normalizedSearch.length === 0
  70. const formattedGlobalUploadLimit = formatBytes(data?.fileSizeLimit ?? 0)
  71. const hasNoApiKeys =
  72. isErrorBuckets && bucketsError.message.includes('Project has no active API keys')
  73. const handleLoadMoreBuckets = useCallback(() => {
  74. if (hasNextPage && !isFetchingBuckets) {
  75. fetchNextPage()
  76. }
  77. }, [hasNextPage, isFetchingBuckets, fetchNextPage])
  78. const handleRefresh = useCallback(() => {
  79. refetchBuckets()
  80. }, [refetchBuckets])
  81. useFilesBucketsShortcuts({
  82. searchInputRef,
  83. setFilterString,
  84. sortBucket,
  85. setSortBucket,
  86. setCreateVisible: setVisible,
  87. onRefresh: handleRefresh,
  88. })
  89. return (
  90. <>
  91. <PageContainer>
  92. <PageSection>
  93. <PageSectionContent className="h-full gap-y-4">
  94. {isLoadingBuckets && <GenericSkeletonLoader />}
  95. {isErrorBuckets && (
  96. <>
  97. {hasNoApiKeys ? (
  98. <Admonition type="warning" title="Project has no active API keys enabled">
  99. <p className="leading-normal! text-sm">
  100. The Dashboard relies on having active API keys on the project to function. If
  101. you'd like to use Storage through the Dashboard, create a set of API keys{' '}
  102. <InlineLink href={`/project/${ref}/settings/api-keys/new`}>here</InlineLink>.
  103. </p>
  104. </Admonition>
  105. ) : (
  106. <AlertError error={bucketsError} subject="Failed to retrieve buckets" />
  107. )}
  108. </>
  109. )}
  110. {isSuccessBuckets && (
  111. <>
  112. {hasNoBuckets ? (
  113. <EmptyBucketState bucketType="files" onCreateBucket={() => setVisible(true)} />
  114. ) : (
  115. <>
  116. <div className="flex grow justify-between gap-x-2 items-center mb-4">
  117. <div className="flex items-center gap-x-2">
  118. <ShortcutTooltip
  119. shortcutId={SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH}
  120. label="Search buckets"
  121. side="bottom"
  122. >
  123. <Input
  124. ref={searchInputRef}
  125. size="tiny"
  126. className="grow lg:grow-0 w-52"
  127. placeholder="Search for a bucket"
  128. value={filterString}
  129. onChange={(e) => setFilterString(e.target.value)}
  130. icon={<Search />}
  131. />
  132. </ShortcutTooltip>
  133. <DropdownMenu>
  134. <DropdownMenuTrigger asChild>
  135. <Button type="default" icon={<ArrowDownNarrowWide />}>
  136. Sorted by {sortBucket === 'alphabetical' ? 'name' : 'created at'}
  137. </Button>
  138. </DropdownMenuTrigger>
  139. <DropdownMenuContent align="start" className="w-40">
  140. <DropdownMenuRadioGroup
  141. value={sortBucket}
  142. onValueChange={(value) => setSortBucket(value as STORAGE_BUCKET_SORT)}
  143. >
  144. <DropdownMenuRadioItem value="alphabetical">
  145. Sort by name
  146. </DropdownMenuRadioItem>
  147. <DropdownMenuRadioItem value="created_at">
  148. Sort by created at
  149. </DropdownMenuRadioItem>
  150. </DropdownMenuRadioGroup>
  151. </DropdownMenuContent>
  152. </DropdownMenu>
  153. <ShortcutTooltip
  154. shortcutId={SHORTCUT_IDS.STORAGE_BUCKETS_REFRESH}
  155. side="bottom"
  156. >
  157. <Button
  158. type="default"
  159. icon={<RefreshCw />}
  160. loading={isFetchingBuckets}
  161. onClick={handleRefresh}
  162. >
  163. Refresh
  164. </Button>
  165. </ShortcutTooltip>
  166. </div>
  167. <ShortcutTooltip
  168. shortcutId={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
  169. label="Create new bucket"
  170. side="bottom"
  171. >
  172. <CreateBucketButton onClick={() => setVisible(true)} />
  173. </ShortcutTooltip>
  174. </div>
  175. <Card>
  176. <BucketsTable
  177. buckets={fileBuckets}
  178. projectRef={ref ?? '_'}
  179. filterString={filterString}
  180. formattedGlobalUploadLimit={formattedGlobalUploadLimit}
  181. pagination={{
  182. hasMore: hasNextPage,
  183. isLoadingMore: isFetchingBuckets,
  184. onLoadMore: handleLoadMoreBuckets,
  185. }}
  186. />
  187. </Card>
  188. </>
  189. )}
  190. </>
  191. )}
  192. </PageSectionContent>
  193. </PageSection>
  194. </PageContainer>
  195. <CreateBucketModal open={visible} onOpenChange={setVisible} />
  196. </>
  197. )
  198. }