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