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