diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx index 0b72b150ecb..b5ba3d43d2a 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx @@ -5,8 +5,9 @@ import type { UpdateOAuthClientParams, } from '@supabase/supabase-js' import { useParams } from 'common' -import { Trash2, Upload, X } from 'lucide-react' -import { useEffect, useRef, useState, type ChangeEvent } from 'react' +import { Storage } from 'icons' +import { ImageOff, Trash2, X } from 'lucide-react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { @@ -39,6 +40,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray' import * as z from 'zod' +import { LogoPicker } from './LogoPicker' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' @@ -95,12 +97,10 @@ export const CreateOrUpdateOAuthAppSheet = ({ onCancel, }: CreateOrUpdateOAuthAppSheetProps) => { const { ref: projectRef } = useParams() - const uploadButtonRef = useRef(null) const [showRegenerateDialog, setShowRegenerateDialog] = useState(false) - const [logoFile, setLogoFile] = useState() + const [storagePickerOpen, setStoragePickerOpen] = useState(false) const [logoUrl, setLogoUrl] = useState() - const [logoRemoved, setLogoRemoved] = useState(false) const isEditMode = !!appToEdit const hasLogo = logoUrl !== undefined @@ -136,10 +136,14 @@ export const CreateOrUpdateOAuthAppSheet = ({ }, }) + useEffect(() => { + if (!visible) { + setStoragePickerOpen(false) + } + }, [visible]) + useEffect(() => { if (visible) { - setLogoFile(undefined) - setLogoRemoved(false) if (appToEdit) { form.reset({ name: appToEdit.client_name, @@ -166,36 +170,12 @@ export const CreateOrUpdateOAuthAppSheet = ({ } }, [visible, appToEdit, form]) - const onFileUpload = async (event: ChangeEvent) => { - event.persist() - const files = event.target.files - if (files && files.length > 0) { - const file = files[0] - setLogoFile(file) - setLogoUrl(URL.createObjectURL(file)) - setLogoRemoved(false) - event.target.value = '' - } - } - const onSubmit = async (data: z.infer) => { const validRedirectUris = data.redirect_uris .map((uri) => uri.value.trim()) .filter((uri) => uri !== '') - let uploadedLogoUri: string | undefined = undefined - - if (logoRemoved) { - uploadedLogoUri = '' - } else if (logoFile) { - const reader = new FileReader() - uploadedLogoUri = await new Promise((resolve) => { - reader.onloadend = () => resolve(reader.result as string) - reader.readAsDataURL(logoFile) - }) - } else if (logoUrl) { - uploadedLogoUri = logoUrl - } + const uploadedLogoUri = data.logo_uri?.trim() ?? '' if (isEditMode && appToEdit) { const payload: UpdateOAuthClientParams & { token_endpoint_auth_method?: string } = { @@ -252,21 +232,32 @@ export const CreateOrUpdateOAuthAppSheet = ({ }) } - const handleUploadLogo = () => uploadButtonRef.current?.click() + const handlePickLogoFromStorage = (uri: string) => { + setLogoUrl(uri) + form.setValue('logo_uri', uri) + } + const handleRemoveLogo = () => { - setLogoFile(undefined) setLogoUrl(undefined) - setLogoRemoved(true) + form.setValue('logo_uri', '') } return ( <> + {projectRef ? ( + + ) : null} onCancel()}>
@@ -305,47 +296,62 @@ export const CreateOrUpdateOAuthAppSheet = ({ ( - + render={({ field }) => ( + -
- -
- - {hasLogo && ( -
+
+
+ { + field.onChange(event) + const next = event.target.value.trim() + setLogoUrl(next.length > 0 ? next : undefined) + }} + /> + {projectRef ? ( + + ) : null} +
+ {field.value ? ( +
-
diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/LogoPicker.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/LogoPicker.tsx new file mode 100644 index 00000000000..3c5dd5305f7 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/LogoPicker.tsx @@ -0,0 +1,124 @@ +import { useBreakpoint } from 'common' +import { ChevronLeft } from 'lucide-react' +import { useEffect, useState } from 'react' +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from 'ui' + +import { BucketFilePickerExplorer } from '../../Storage/BucketFilePickerDialog/BucketFilePickerExplorer' +import { BucketFilePickerStateContextProvider } from '../../Storage/BucketFilePickerDialog/BucketFilePickerState' +import { BucketsPicker } from '../../Storage/BucketsPickerDialog/BucketsPicker' +import type { Bucket } from '@/data/storage/buckets-query' + +export type StorageFilePickerProps = { + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (value: string) => void +} + +export function LogoPicker({ open, onOpenChange, onSelect }: StorageFilePickerProps) { + const [selectedBucket, setSelectedBucket] = useState(null) + + const isMobileLayout = useBreakpoint('lg') + + useEffect(() => { + if (!open) { + setSelectedBucket(null) + } + }, [open]) + + const handleSelect = (value: string) => { + onSelect(value) + onOpenChange(false) + } + + return ( + <> + {isMobileLayout ? ( + + + {selectedBucket ? ( + + + Choose a file + + ) : ( + + Select a bucket + + )} +
+ {selectedBucket ? ( + + + + ) : ( + + )} +
+
+
+ ) : ( + + + {selectedBucket ? ( + + + Choose a file + + ) : ( + + Select a bucket + + )} + +
+ {selectedBucket ? ( + + + + ) : ( + + )} +
+
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerColumn.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerColumn.tsx new file mode 100644 index 00000000000..9b0da9e47cf --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerColumn.tsx @@ -0,0 +1,432 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useDebounce } from '@uidotdev/usehooks' +import { useParams } from 'common' +import { AnimatePresence, motion } from 'framer-motion' +import { compact, get, sum, uniqBy } from 'lodash' +import { Upload } from 'lucide-react' +import { DragEventHandler, useCallback, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import { Checkbox, cn } from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants' +import type { StorageItem } from '../Storage.types' +import { formatFolderItems } from '../StorageExplorer/StorageExplorer.utils' +import { useStoragePreference } from '../StorageExplorer/useStoragePreference' +import { uploadFilesToBucket } from './BucketFilePickerDialog.utils' +import { BucketFilePickerRow } from './BucketFilePickerRow' +import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState' +import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' +import { useBucketObjectsInfiniteQuery } from '@/data/storage/bucket-objects-infinite-query' +import { storageKeys } from '@/data/storage/keys' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { formatBytes } from '@/lib/helpers' +import { noop } from '@/lib/void' + +const SelectAllCheckbox = ({ + columnFiles, + selectedFilesFromColumn, + onChange, +}: { + columnFiles: StorageItem[] + selectedFilesFromColumn: StorageItem[] + onChange: () => void +}) => ( + +) + +const DragOverOverlay = ({ + isOpen, + onDragLeave, + onDrop, + folderIsEmpty, +}: { + isOpen: boolean + onDragLeave: () => void + onDrop: () => void + folderIsEmpty: boolean +}) => { + return ( + + {isOpen && ( + +
+ {!folderIsEmpty && ( +
+ +

+ Drop your files to upload to this folder +

+
+ )} +
+
+ )} +
+ ) +} + +export interface BucketFilePickerColumnProps { + index: number + fullWidth?: boolean +} + +export const BucketFilePickerColumn = ({ + index, + fullWidth = false, +}: BucketFilePickerColumnProps) => { + const { ref: projectRef } = useParams() + const queryClient = useQueryClient() + + const [isDraggedOver, setIsDraggedOver] = useState(false) + const columnRef = useRef(null) + + const { hostEndpoint } = useProjectApiUrl({ projectRef: projectRef! }) + const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + + const { + columns, + itemSearchString, + bucket, + maxFiles, + acceptedFileExtensions, + pushColumnAtIndex, + selectedItems, + setSelectedItems, + clearSelectedItems, + selectedFilePreview, + setSelectedFilePreview, + popColumnAtIndex, + } = useBucketFilePickerStateSnapshot() + + const isFileAccepted = (fileName: string) => { + if (!acceptedFileExtensions || acceptedFileExtensions.length === 0) return true + const ext = fileName.split('.').pop()?.toLowerCase() ?? '' + return acceptedFileExtensions.map((e) => e.replace(/^\./, '').toLowerCase()).includes(ext) + } + + const path = columns.slice(0, index).join('/') + const selectedFolder = columns[index] + const setSelectedFolder = (folderName: string | null) => { + if (folderName) { + pushColumnAtIndex(folderName, index) + } + } + const isLastFolder = index === columns.length + + const { view, sortBy, sortByOrder } = useStoragePreference(projectRef!) + + const debouncedSearchString = useDebounce(itemSearchString, 500) + const { data, isLoading, isFetching, fetchNextPage, hasNextPage } = useBucketObjectsInfiniteQuery( + { + projectRef, + bucketId: bucket.id, + path, + options: { + sortBy: { + column: sortBy, + order: sortByOrder, + }, + // When a user tries to search, only search in the last opened folder (rightmost column) + ...(isLastFolder && debouncedSearchString ? { search: debouncedSearchString } : {}), + }, + } + ) + + const items = useMemo(() => { + const objs = data?.pages.flatMap((page) => page) || [] + return formatFolderItems(objs) + }, [data]) + + const haveSelectedItems = selectedItems.length > 0 + const columnItemsId = items.map((item) => item.id) + const columnFiles = items.filter((item) => item.type === STORAGE_ROW_TYPES.FILE) + const selectedItemsFromColumn = selectedItems.filter((item) => columnItemsId.includes(item.id)) + const selectedFilesFromColumn = selectedItemsFromColumn.filter( + (item) => item.type === STORAGE_ROW_TYPES.FILE + ) + + const columnItems = items.map((item) => ({ ...item, columnIndex: index })) + const columnItemsSize = sum(columnItems.map((item) => get(item, ['metadata', 'size'], 0))) + + const isEmpty = items.filter((item) => item.status !== STORAGE_ROW_STATUS.LOADING).length === 0 + + const getItemKey = useCallback( + (index: number) => { + const item = columnItems[index] + return item?.id || `file-explorer-item-${index}` + }, + [columnItems] + ) + + const itemProps = useMemo( + () => ({ + view: view, + columnIndex: index, + selectedItems, + hideCheckbox: maxFiles === 1, + }), + [view, index, selectedItems, maxFiles] + ) + + const onSelectAllItemsInColumn = () => { + const columnFiles = columnItems.filter((item) => item.type === STORAGE_ROW_TYPES.FILE) + + const columnFilesId = compact(columnFiles.map((item) => item.id)) + const selectedItemsFromColumn = selectedItems.filter( + (item) => item.id && columnFilesId.includes(item.id) + ) + + if (selectedItemsFromColumn.length === columnFiles.length) { + // Deselect all items from column + const updatedSelectedItems = selectedItems.filter( + (item) => item.id && !columnFilesId.includes(item.id) + ) + setSelectedItems(updatedSelectedItems) + } else { + // Select all items from column + const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id') + setSelectedItems(updatedSelectedItems) + } + } + + const onSelectColumnEmptySpace = (columnIndex: number) => { + popColumnAtIndex(columnIndex) + setSelectedFilePreview(undefined) + clearSelectedItems() + } + + const onDragOver: DragEventHandler = (event) => { + if (event) { + event.stopPropagation() + event.preventDefault() + if (event.type === 'dragover' && !isDraggedOver) { + setIsDraggedOver(true) + } + } + } + + const onDrop: DragEventHandler = async (event) => { + onDragOver(event) + + if (!canUpdateStorage) { + toast('You need additional permissions to upload files to this project') + return + } + if (!hostEndpoint) { + toast.error('Unable to upload files at this time. Please try again.') + return + } + + const files = Array.from(event.dataTransfer?.files ?? []) as File[] + await uploadFilesToBucket({ + files, + projectRef: projectRef!, + hostEndpoint: hostEndpoint, + bucketName: bucket.name, + bucketId: bucket.id, + currentPath: columns.slice(0, index).join('/'), + queryClient, + }) + + queryClient.invalidateQueries({ + queryKey: storageKeys.objects(projectRef!, bucket.id, columns.slice(0, index).join('/')), + }) + + setIsDraggedOver(false) + } + + return ( + <> +
{ + const eventTarget = get(event.target, ['className'], '') + if (typeof eventTarget === 'string' && eventTarget.includes('react-contexify')) return + onSelectColumnEmptySpace(index) + }} + > + {/* Checkbox selection for select all */} + {view === STORAGE_VIEWS.COLUMNS && maxFiles !== 1 && ( +
event.stopPropagation()} + > + {columnFiles.length > 0 ? ( + <> + onSelectAllItemsInColumn()} + /> +

+ Select all {columnFiles.length} files +

+ + ) : ( +

No files available for selection

+ )} +
+ )} + + {/* List Interface Header */} + {view === STORAGE_VIEWS.LIST && ( +
+
+ {maxFiles !== 1 && ( + onSelectAllItemsInColumn()} + /> + )} +

Name

+
+

Size

+

Type

+

Created at

+

Last modified at

+
+ )} + + {/* Shimmering loaders while fetching contents */} + {isLoading && ( +
+ + + +
+ )} + + {/* Column Interface */} + {columnItems.length > 0 && ( + (index !== 0 && index === columnItems.length ? 85 : 37)} + // eslint-disable-next-line react/no-unstable-nested-components + ItemComponent={(props) => { + const item = props.item + const isPreviewed = !!( + selectedFilePreview?.id !== null && selectedFilePreview?.id === item.id + ) + const isOpened = + selectedFolder !== null && + item.type === STORAGE_ROW_TYPES.FOLDER && + selectedFolder === item.name + + return ( + i.id === item.id)} + hideCheckbox={maxFiles === 1} + isDisabled={ + item.type === STORAGE_ROW_TYPES.FILE && !isFileAccepted(item.name ?? '') + } + onClick={(event) => { + event.stopPropagation() + event.preventDefault() + if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) { + if (item.type === STORAGE_ROW_TYPES.FOLDER) { + setSelectedFilePreview(undefined) + setSelectedFolder(item.name) + } else { + setSelectedFilePreview(item) + // deselect all folders when previewing a file + popColumnAtIndex(index) + clearSelectedItems() + } + } + }} + /> + ) + }} + LoaderComponent={LoaderForIconMenuItems} + hasNextPage={hasNextPage} + isLoadingNextPage={isFetching} + onLoadNextPage={fetchNextPage} + /> + )} + + {debouncedSearchString.length > 0 && isEmpty && !isLoading && ( +
+

No results found in this folder

+

+ Your search for "{debouncedSearchString}" did not return any results +

+
+ )} + + {debouncedSearchString.length === 0 && isEmpty && !isLoading && ( +
+

Drop your files here

+

+ Or upload them via the "Upload files" button above +

+
+ )} + + setIsDraggedOver(false)} + onDrop={() => setIsDraggedOver(false)} + /> + + {/* List interface footer */} + {view === STORAGE_VIEWS.LIST && ( +
+

+ {formatBytes(columnItemsSize)} for {columnItems.length} items +

+
+ )} +
+ {selectedFolder ? ( + + ) : null} + + ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerDialog.utils.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerDialog.utils.tsx new file mode 100644 index 00000000000..a50bbf960bc --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerDialog.utils.tsx @@ -0,0 +1,44 @@ +import { QueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { storageKeys } from '@/data/storage/keys' +import { createProjectSupabaseClient } from '@/lib/project-supabase-client' + +export async function uploadFilesToBucket({ + files, + projectRef, + hostEndpoint, + bucketName, + bucketId, + currentPath, + queryClient, +}: { + files: File[] + projectRef: string + hostEndpoint: string + bucketName: string + bucketId: string + currentPath: string + queryClient: QueryClient +}) { + if (files.length === 0) return + + const client = await createProjectSupabaseClient(projectRef, hostEndpoint) + let successCount = 0 + + for (const file of files) { + const filePath = currentPath ? `${currentPath}/${file.name}` : file.name + const { error } = await client.storage.from(bucketName).upload(filePath, file, { upsert: true }) + if (error) { + toast.error(`Failed to upload ${file.name}: ${error.message}`) + } else { + successCount++ + } + } + + if (successCount > 0) { + toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''}`) + const queryKey = storageKeys.objects(projectRef, bucketId, '') + await queryClient.refetchQueries({ queryKey, type: 'active' }) + } +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerExplorer.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerExplorer.tsx new file mode 100644 index 00000000000..d1bc400af9c --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerExplorer.tsx @@ -0,0 +1,45 @@ +import { useParams } from 'common' +import { cn } from 'ui' + +import { STORAGE_VIEWS } from '../Storage.constants' +import { useStoragePreference } from '../StorageExplorer/useStoragePreference' +import { BucketFilePickerColumn } from './BucketFilePickerColumn' +import { BucketFilePickerHeader } from './BucketFilePickerHeader' +import { BucketFilePickerHeaderSelection } from './BucketFilePickerHeaderSelection' +import { PreviewPane } from './BucketFilePickerPreviewPane' +import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState' + +export function BucketFilePickerExplorer({ onSelect }: { onSelect: (value: string) => void }) { + const { ref: projectRef } = useParams() + const { view } = useStoragePreference(projectRef!) + const { selectedItems, columns } = useBucketFilePickerStateSnapshot() + + return ( +
+
+ {selectedItems.length === 0 ? ( + + ) : ( + + )} +
+
+ {view === STORAGE_VIEWS.COLUMNS ? ( +
+ +
+ ) : ( + + )} +
+ +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeader.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeader.tsx new file mode 100644 index 00000000000..b5c158af756 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeader.tsx @@ -0,0 +1,375 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { + ArrowLeft, + Check, + ChevronRight, + Columns, + List, + RefreshCw, + Search, + Upload, + X, +} from 'lucide-react' +import { useRef, useState, type ChangeEvent } from 'react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' + +import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants' +import { useStoragePreference } from '../StorageExplorer/useStoragePreference' +import { uploadFilesToBucket } from './BucketFilePickerDialog.utils' +import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' +import { storageKeys } from '@/data/storage/keys' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' + +const VIEW_OPTIONS = [ + { key: STORAGE_VIEWS.COLUMNS, name: 'As columns' }, + { key: STORAGE_VIEWS.LIST, name: 'As list' }, +] + +const SORT_BY_OPTIONS = [ + { key: STORAGE_SORT_BY.NAME, name: 'Name' }, + { key: STORAGE_SORT_BY.CREATED_AT, name: 'Time created' }, + { key: STORAGE_SORT_BY.UPDATED_AT, name: 'Time modified' }, + { key: STORAGE_SORT_BY.LAST_ACCESSED_AT, name: 'Time last accessed' }, +] + +const SORT_ORDER_OPTIONS = [ + { key: STORAGE_SORT_BY_ORDER.ASC, name: 'Ascending' }, + { key: STORAGE_SORT_BY_ORDER.DESC, name: 'Descending' }, +] + +const HeaderBreadcrumbs = ({ + breadcrumbs, + selectBreadcrumb, +}: { + breadcrumbs: string[] + selectBreadcrumb: (i: number) => void +}) => { + // Max 5 crumbs, otherwise replace middle segment with ellipsis and only + // have the first 2 and last 2 crumbs visible + const ellipsis = '...' + const breadcrumbsWithIndexes = breadcrumbs.map((name: string, index: number) => { + return { name, index } + }) + + const formattedBreadcrumbs = + breadcrumbsWithIndexes.length <= 5 + ? breadcrumbsWithIndexes + : breadcrumbsWithIndexes + .slice(0, 2) + .concat([{ name: ellipsis, index: -1 }]) + .concat( + breadcrumbsWithIndexes.slice( + breadcrumbsWithIndexes.length - 2, + breadcrumbsWithIndexes.length + ) + ) + + return ( +
+ {formattedBreadcrumbs.map((crumb, idx: number) => { + const isEllipsis = crumb.name === ellipsis + const isActive = crumb.index === breadcrumbs.length - 1 + + return ( +
+ {idx !== 0 && ( + + )} + {isEllipsis ? ( + {crumb.name} + ) : isActive ? ( + {crumb.name} + ) : ( + + )} +
+ ) + })} +
+ ) +} + +export const BucketFilePickerHeader = () => { + const { ref: projectRef } = useParams() + const queryClient = useQueryClient() + + const [isSearching, setIsSearching] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + const uploadButtonRef = useRef(null) + + const { hostEndpoint } = useProjectApiUrl({ projectRef: projectRef! }) + + const { view, sortBy, sortByOrder, setSortBy, setSortByOrder, setView } = useStoragePreference( + projectRef! + ) + + const { + bucket, + columns, + itemSearchString, + setItemSearchString, + popColumn, + popColumnAtIndex, + setSelectedFilePreview, + } = useBucketFilePickerStateSnapshot() + + const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + + const breadcrumbs = columns + const backDisabled = columns.length < 1 + + const onSelectBack = () => { + popColumn() + setSelectedFilePreview(undefined) + } + + const onSelectUpload = () => { + if (uploadButtonRef.current) { + uploadButtonRef.current.click() + } + } + + const handleFilesUpload = async (event: ChangeEvent) => { + if (!hostEndpoint) { + console.error('Host endpoint not available') + return + } + const files = Array.from(event.target.files || []) + try { + setIsUploading(true) + await uploadFilesToBucket({ + files, + projectRef: projectRef!, + hostEndpoint, + bucketName: bucket.name, + bucketId: bucket.id, + currentPath: columns.join('/'), + queryClient, + }) + queryClient.invalidateQueries({ + queryKey: storageKeys.objects(projectRef!, bucket.id, columns.join('/')), + }) + } catch (error) { + console.error('Failed to upload files:', error) + // Consider showing a toast notification to the user + } finally { + event.target.value = '' + setIsUploading(false) + } + } + + /** Methods for searching */ + const toggleSearch = () => { + setIsSearching(true) + } + + const onCancelSearch = () => { + setIsSearching(false) + setItemSearchString('') + } + + /** Methods for breadcrumbs */ + + const selectBreadcrumb = (columnIndex: number) => { + popColumnAtIndex(columnIndex) + } + + const refreshData = async () => { + setIsRefreshing(true) + const queryKey = storageKeys.objects(projectRef!, bucket.id, '').filter(Boolean) + try { + await queryClient.refetchQueries({ queryKey: queryKey, type: 'active' }) + } finally { + setIsRefreshing(false) + } + } + + return ( +
+
+
+ {/* Navigation */} +
+ {breadcrumbs.length > 0 && ( + <> + + + + + + + + {VIEW_OPTIONS.map((option) => ( + setView(option.key)}> +
+

{option.name}

+ {view === option.key && ( + + )} +
+
+ ))} + + + Sort by + + {SORT_BY_OPTIONS.map((option) => ( + setSortBy(option.key)}> +
+

{option.name}

+ {sortBy === option.key && ( + + )} +
+
+ ))} +
+
+ + Sort order + + {SORT_ORDER_OPTIONS.map((option) => ( + setSortByOrder(option.key)} + > +
+

{option.name}

+ {sortByOrder === option.key && ( + + )} +
+
+ ))} +
+
+
+
+
+ +
+
+
+ +
+ } + type="text" + disabled={!canUpdateStorage} + loading={isUploading} + onClick={onSelectUpload} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateStorage + ? 'You need additional permissions to upload files' + : undefined, + }, + }} + > + Upload files + +
+ +
+
+ {isSearching ? ( + } + actions={[ +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeaderSelection.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeaderSelection.tsx new file mode 100644 index 00000000000..db44a8a8722 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeaderSelection.tsx @@ -0,0 +1,27 @@ +import { X } from 'lucide-react' +import { Button } from 'ui' + +import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState' + +export const BucketFilePickerHeaderSelection = () => { + const { selectedItems, clearSelectedItems } = useBucketFilePickerStateSnapshot() + + return ( +
+ +
+ setSelectedFilePreview(undefined)} + /> +
+
+ + {/* Preview Thumbnail*/} +
+
+ +
+
+ +
+ {/* Preview Information */} +
+
{file.name}
+ {file.isCorrupted && ( +
+ +

+ File is corrupted, please delete and reupload this file again +

+
+ )} + {mimeType && ( +

+ {mimeType} + {size && - {size}} +

+ )} +
+ + {/* Preview Metadata */} +
+
+ +

{createdAt}

+
+
+ +

{updatedAt}

+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerRow.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerRow.tsx new file mode 100644 index 00000000000..5ec97e3b1bd --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerRow.tsx @@ -0,0 +1,184 @@ +import { FilesBucket as FilesBucketIcon } from 'icons' +import { AlertCircle, File, Film, FolderOpen, Image, LoaderCircle, Music } from 'lucide-react' +import type { CSSProperties, MouseEvent } from 'react' +import { Checkbox, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants' +import { type StorageItem } from '../Storage.types' +import { formatBytes } from '@/lib/helpers' + +const RowIcon = ({ + view, + status, + fileType, + isOpened = false, + mimeType, +}: { + view: STORAGE_VIEWS + status: STORAGE_ROW_STATUS + fileType: string + isOpened?: boolean + mimeType: string | undefined +}) => { + if (view === STORAGE_VIEWS.LIST && status === STORAGE_ROW_STATUS.LOADING) { + return ( + + ) + } + + if (fileType === STORAGE_ROW_TYPES.FOLDER) { + return isOpened ? ( + + ) : ( + + ) + } + + if (mimeType?.includes('image')) { + return + } + + if (mimeType?.includes('audio')) { + return + } + + if (mimeType?.includes('video')) { + return + } + + return +} + +interface BucketFilePickerRowProps { + item: StorageItem + view: STORAGE_VIEWS + isSelected: boolean + isPreviewed: boolean + isOpened: boolean + isDisabled?: boolean + hideCheckbox: boolean + onCheck: (isShiftKeyHeld: boolean) => void + onClick?: (event: MouseEvent) => void + style?: CSSProperties +} + +export const BucketFilePickerRow = ({ + item, + view = STORAGE_VIEWS.COLUMNS, + onCheck, + onClick, + isSelected, + isPreviewed, + isOpened, + isDisabled = false, + hideCheckbox, + style, +}: BucketFilePickerRowProps) => { + const size = item.metadata ? formatBytes(item.metadata.size) : '-' + const mimeType = item.metadata ? item.metadata.mimetype : '-' + const createdAt = item.created_at ? new Date(item.created_at).toLocaleString() : '-' + const updatedAt = item.updated_at ? new Date(item.updated_at).toLocaleString() : '-' + + const nameWidth = + view === STORAGE_VIEWS.LIST && item.isCorrupted + ? `calc(100% - 60px)` + : view === STORAGE_VIEWS.LIST && !item.isCorrupted + ? `calc(100% - 50px)` + : '100%' + + return ( +
+
+
+
event.stopPropagation()}> +
+ +
+ {!hideCheckbox && ( + { + event.stopPropagation() + onCheck((event.nativeEvent as KeyboardEvent).shiftKey) + }} + /> + )} +
+

+ {item.name} +

+ {item.isCorrupted && ( + + + + + + File is corrupted, please delete and reupload again. + + + )} +
+ + {view === STORAGE_VIEWS.LIST && ( + <> +

{size}

+

{mimeType}

+

{createdAt}

+

{updatedAt}

+ + )} + +
+ // Stops click event from this div, to resolve an issue with menu item's click event triggering unexpected row select + event.stopPropagation() + } + > + {item.status === STORAGE_ROW_STATUS.LOADING && ( + + )} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerState.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerState.tsx new file mode 100644 index 00000000000..7ff3c850a1d --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerState.tsx @@ -0,0 +1,86 @@ +import { createContext, PropsWithChildren, useContext, useMemo } from 'react' +import { proxy, useSnapshot } from 'valtio' + +import { StorageItemWithColumn } from '@/components/interfaces/Storage/Storage.types' +import type { Bucket } from '@/data/storage/buckets-query' + +function createBucketFilePickerState({ + bucket, + maxFiles, + acceptedFileExtensions, +}: { + bucket: Bucket + maxFiles: number + acceptedFileExtensions?: string[] +}) { + const state = proxy({ + bucket: bucket, + maxFiles: maxFiles, + acceptedFileExtensions: acceptedFileExtensions, + + columns: [] as string[], + popColumn: () => { + const lastColumnIndex = state.columns.length - 1 + state.columns = state.columns.slice(0, lastColumnIndex) + }, + popColumnAtIndex: (index: number) => { + state.columns = state.columns.slice(0, index) + }, + pushColumnAtIndex: (column: string, index: number) => { + state.columns = state.columns.slice(0, index).concat([column]) + }, + + selectedItems: [] as StorageItemWithColumn[], + setSelectedItems: (items: StorageItemWithColumn[]) => (state.selectedItems = items), + clearSelectedItems: (columnIndex?: number) => { + if (columnIndex !== undefined) { + state.selectedItems = state.selectedItems.filter((item) => item.columnIndex !== columnIndex) + } else { + state.selectedItems = [] + } + }, + + itemSearchString: '', + setItemSearchString: (value: string) => (state.itemSearchString = value), + + selectedFilePreview: undefined as StorageItemWithColumn | undefined, + setSelectedFilePreview: (file?: StorageItemWithColumn) => (state.selectedFilePreview = file), + }) + + return state +} + +export type BucketFilePickerState = ReturnType + +const DEFAULT_STATE_CONFIG = { + bucket: {} as Bucket, + maxFiles: 1 as const, +} + +const BucketFilePickerStateContext = createContext( + createBucketFilePickerState(DEFAULT_STATE_CONFIG) +) + +export const BucketFilePickerStateContextProvider = ({ + bucket, + maxFiles, + acceptedFileExtensions, + children, +}: PropsWithChildren<{ bucket: Bucket; maxFiles: number; acceptedFileExtensions?: string[] }>) => { + const state = useMemo( + () => createBucketFilePickerState({ bucket, maxFiles, acceptedFileExtensions }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucket, maxFiles, acceptedFileExtensions?.join(',')] + ) + + return ( + + {children} + + ) +} + +export function useBucketFilePickerStateSnapshot(options?: Parameters[1]) { + const state = useContext(BucketFilePickerStateContext) + return useSnapshot(state, options) as BucketFilePickerState +} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/index.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/index.tsx new file mode 100644 index 00000000000..f829967b1ac --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/index.tsx @@ -0,0 +1,81 @@ +import { useBreakpoint } from 'common' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from 'ui' + +import { BucketFilePickerExplorer } from './BucketFilePickerExplorer' +import { BucketFilePickerStateContextProvider } from './BucketFilePickerState' +import type { Bucket } from '@/data/storage/buckets-query' + +export type BucketsFilePickerDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + selectedBucket: Bucket + acceptedFileExtensions?: string[] + onSelect: (value: string) => void +} + +export function BucketsFilePickerDialog({ + open, + onOpenChange, + selectedBucket, + acceptedFileExtensions, + onSelect, +}: BucketsFilePickerDialogProps) { + const isMobileLayout = useBreakpoint('lg') + + const handleSelect = (value: string) => { + onSelect(value) + onOpenChange(false) + } + + return ( + <> + {isMobileLayout ? ( + + + + Choose a file + +
+ + + +
+
+
+ ) : ( + + + + Choose a file + +
+ + + +
+
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketTableHeader.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketTableHeader.tsx new file mode 100644 index 00000000000..c78b1c9e77d --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketTableHeader.tsx @@ -0,0 +1,41 @@ +import { TableHead, TableHeader, TableRow } from 'ui' + +import { + VirtualizedTableHead, + VirtualizedTableHeader, + VirtualizedTableRow, +} from '@/components/ui/VirtualizedTable' + +type BucketTableMode = 'standard' | 'virtualized' + +type BucketTableHeaderProps = { + mode: BucketTableMode + hasBuckets?: boolean +} + +export const BucketTableHeader = ({ mode, hasBuckets = true }: BucketTableHeaderProps) => { + const BucketTableHeader = mode === 'standard' ? TableHeader : VirtualizedTableHeader + const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow + const BucketTableHead = mode === 'standard' ? TableHead : VirtualizedTableHead + + const stickyClasses = 'sticky top-0 z-10 bg-200' + + return ( + + + {hasBuckets && ( + + Icon + + )} + Name + Policies + File size limit + Allowed MIME types + + Actions + + + + ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketTableRow.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketTableRow.tsx new file mode 100644 index 00000000000..41e6989fe89 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketTableRow.tsx @@ -0,0 +1,157 @@ +import { FilesBucket as FilesBucketIcon } from 'icons' +import { ChevronRight } from 'lucide-react' +import { KeyboardEventHandler, MouseEventHandler } from 'react' +import { Badge, cn, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import type { AllowedBucketType } from './types' +import { PUBLIC_BUCKET_TOOLTIP } from '@/components/interfaces/Storage/Storage.constants' +import { useBucketPolicyCount } from '@/components/interfaces/Storage/useBucketPolicyCount' +import { VirtualizedTableCell, VirtualizedTableRow } from '@/components/ui/VirtualizedTable' +import { Bucket } from '@/data/storage/buckets-query' +import { formatBytes } from '@/lib/helpers' + +type BucketTableMode = 'standard' | 'virtualized' + +type BucketTableEmptyStateProps = { + mode: BucketTableMode + filterString: string +} + +export const BucketTableEmptyState = ({ mode, filterString }: BucketTableEmptyStateProps) => { + const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow + const BucketTableCell = mode === 'standard' ? TableCell : VirtualizedTableCell + + return ( + + +

No results found

+

+ Your search for “{filterString}” did not return any results +

+
+
+ ) +} + +type BucketTableRowProps = { + mode: BucketTableMode + bucket: Bucket + onSelectBucket: (bucket: Bucket) => void + allowedBucketType: AllowedBucketType + formattedGlobalUploadLimit: string +} + +export const BucketTableRow = ({ + mode, + bucket, + onSelectBucket, + allowedBucketType, + formattedGlobalUploadLimit, +}: BucketTableRowProps) => { + const { getPolicyCount } = useBucketPolicyCount() + + const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow + const BucketTableCell = mode === 'standard' ? TableCell : VirtualizedTableCell + + const isDisabled = !( + allowedBucketType === 'all' || + (allowedBucketType === 'public' && bucket.public) || + (allowedBucketType === 'private' && !bucket.public) + ) + + const handleRowActivate: MouseEventHandler = (e) => { + e.preventDefault() + if (isDisabled) return + onSelectBucket(bucket) + } + + const handleRowKeyDown: KeyboardEventHandler = (e) => { + if (isDisabled) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelectBucket(bucket) + } + } + + return ( + + + td]:hover:bg-transparent cursor-not-allowed' + )} + onClick={handleRowActivate} + onKeyDown={handleRowKeyDown} + tabIndex={isDisabled ? -1 : 0} + aria-disabled={isDisabled || undefined} + > + + + + +
+

{bucket.id}

+ {bucket.public && ( + + + + Public + + + {PUBLIC_BUCKET_TOOLTIP} + + )} +
+
+ + +

{getPolicyCount(bucket.id)}

+
+ + +

+ {bucket.file_size_limit + ? formatBytes(bucket.file_size_limit) + : `Unset (${formattedGlobalUploadLimit})`} +

+
+ + +

+ {bucket.allowed_mime_types ? bucket.allowed_mime_types.join(', ') : 'Any'} +

+
+ + + {!isDisabled && ( + <> +
+ +
+ + + )} +
+
+
+ {isDisabled && ( + + {allowedBucketType === 'public' + ? 'Private buckets are not selectable for this action. Please select a public bucket.' + : 'Public buckets are not selectable for this action. Please select a private bucket.'} + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx new file mode 100644 index 00000000000..15a6f353a90 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx @@ -0,0 +1,165 @@ +import { useDebounce } from '@uidotdev/usehooks' +import { useParams } from 'common' +import { ArrowDownNarrowWide, Search } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' +import { + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { CreateBucketModal } from '../CreateBucketModal' +import { EmptyBucketState } from '../EmptyBucketState' +import { CreateBucketButton } from '../NewBucketButton' +import { STORAGE_BUCKET_SORT } from '../Storage.constants' +import { useStoragePreference } from '../StorageExplorer/useStoragePreference' +import { BucketsTable } from './BucketsTable' +import type { AllowedBucketType } from './types' +import AlertError from '@/components/ui/AlertError' +import { InlineLink } from '@/components/ui/InlineLink' +import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' +import { usePaginatedBucketsQuery, type Bucket } from '@/data/storage/buckets-query' +import { IS_PLATFORM } from '@/lib/constants' +import { formatBytes } from '@/lib/helpers' + +export const BucketsPicker = ({ + onSelectBucket, + allowedBucketType = 'all', +}: { + onSelectBucket: (bucket: Bucket) => void + allowedBucketType?: AllowedBucketType +}) => { + const { ref: projectRef } = useParams() + const [createBucketShown, showCreateBucket] = useState(false) + const { sortBucket, setSortBucket } = useStoragePreference(projectRef!) + + const [filterString, setFilterString] = useState('') + const debouncedFilterString = useDebounce(filterString, 250) + const normalizedSearch = debouncedFilterString.trim() + + const sortColumn = sortBucket === STORAGE_BUCKET_SORT.ALPHABETICAL ? 'name' : 'created_at' + const sortOrder = sortBucket === STORAGE_BUCKET_SORT.ALPHABETICAL ? 'asc' : 'desc' + + const { data } = useProjectStorageConfigQuery({ projectRef }, { enabled: IS_PLATFORM }) + const { + data: bucketsData, + error: bucketsError, + isError: isErrorBuckets, + isPending: isLoadingBuckets, + isSuccess: isSuccessBuckets, + isFetching: isFetchingBuckets, + fetchNextPage, + hasNextPage, + } = usePaginatedBucketsQuery({ + projectRef, + search: normalizedSearch.length > 0 ? normalizedSearch : undefined, + sortColumn, + sortOrder, + }) + const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData]) + const fileBuckets = buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD') + const hasNoBuckets = fileBuckets.length === 0 && normalizedSearch.length === 0 + + const formattedGlobalUploadLimit = formatBytes(data?.fileSizeLimit ?? 0) + + const hasNoApiKeys = + isErrorBuckets && bucketsError.message.includes('Project has no active API keys') + + const handleLoadMoreBuckets = useCallback(() => { + if (hasNextPage && !isFetchingBuckets) { + fetchNextPage() + } + }, [hasNextPage, isFetchingBuckets, fetchNextPage]) + + return ( +
+ {isLoadingBuckets && } + {isErrorBuckets && ( + <> + {hasNoApiKeys ? ( + +

+ The Dashboard relies on having active API keys on the project to function. If you'd + like to use Storage through the Dashboard, create a set of API keys{' '} + here. +

+
+ ) : ( + + )} + + )} + {isSuccessBuckets && ( + <> + {hasNoBuckets ? ( + showCreateBucket(true)} + className="h-full justify-center" + /> + ) : ( + <> +
+
+ setFilterString(e.target.value)} + icon={} + /> + + + + + + setSortBucket(value as STORAGE_BUCKET_SORT)} + > + + Sort by name + + + Sort by created at + + + + +
+ showCreateBucket(true)} /> +
+ + + + + + )} + + )} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsTable.LoadMoreRow.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsTable.LoadMoreRow.tsx new file mode 100644 index 00000000000..6417c688ba8 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsTable.LoadMoreRow.tsx @@ -0,0 +1,47 @@ +import { useIntersectionObserver } from '@uidotdev/usehooks' +import { useEffect, type ReactNode } from 'react' +import { TableCell, TableRow } from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import type { BucketsTablePaginationProps } from './types' +import { VirtualizedTableCell, VirtualizedTableRow } from '@/components/ui/VirtualizedTable' + +type LoadMoreRowProps = { + mode: 'standard' | 'virtualized' + colSpan: number +} & BucketsTablePaginationProps + +export const LoadMoreRow = ({ + mode, + colSpan, + + hasMore = false, + isLoadingMore = false, + onLoadMore, +}: LoadMoreRowProps): ReactNode => { + const [sentinelRef, entry] = useIntersectionObserver({ + threshold: 0, + rootMargin: '200px 0px 200px 0px', + }) + + useEffect(() => { + if (entry?.isIntersecting && hasMore && !isLoadingMore) { + onLoadMore?.() + } + }, [entry?.isIntersecting, hasMore, isLoadingMore, onLoadMore]) + + if (!hasMore && !isLoadingMore) return null + + const RowComponent = mode === 'standard' ? TableRow : VirtualizedTableRow + const CellComponent = mode === 'standard' ? TableCell : VirtualizedTableCell + + return ( + + {Array.from({ length: colSpan }, (_, idx) => ( + + {idx !== 0 && idx !== colSpan - 1 && } + + ))} + + ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsTable.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsTable.tsx new file mode 100644 index 00000000000..a73e9f3c7ef --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsTable.tsx @@ -0,0 +1,124 @@ +import { useRef } from 'react' +import { Table, TableBody } from 'ui' + +import { LoadMoreRow } from './BucketsTable.LoadMoreRow' +import { BucketTableHeader } from './BucketTableHeader' +import { BucketTableEmptyState, BucketTableRow } from './BucketTableRow' +import type { AllowedBucketType, BucketsTablePaginationProps } from './types' +import { VirtualizedTable, VirtualizedTableBody } from '@/components/ui/VirtualizedTable' +import { type Bucket } from '@/data/storage/buckets-query' + +type BucketsTableProps = { + buckets: Bucket[] + projectRef: string + filterString: string + formattedGlobalUploadLimit: string + onSelectBucket: (bucket: Bucket) => void + allowedBucketType: AllowedBucketType + pagination: BucketsTablePaginationProps +} + +export const BucketsTable = (props: BucketsTableProps) => { + const isVirtualized = props.buckets.length > 50 + return isVirtualized ? ( + + ) : ( + + ) +} + +const BucketsTableUnvirtualized = ({ + buckets, + filterString, + formattedGlobalUploadLimit, + onSelectBucket, + allowedBucketType, + pagination: { hasMore = false, isLoadingMore = false, onLoadMore }, +}: BucketsTableProps) => { + const showSearchEmptyState = buckets.length === 0 && filterString.length > 0 + + return ( + + 0} /> + + {showSearchEmptyState ? ( + + ) : ( + buckets.map((bucket) => ( + + )) + )} + + +
+ ) +} + +const BucketsTableVirtualized = ({ + buckets, + filterString, + formattedGlobalUploadLimit, + onSelectBucket, + allowedBucketType, + pagination: { hasMore = false, isLoadingMore = false, onLoadMore }, +}: BucketsTableProps) => { + const showSearchEmptyState = buckets.length === 0 && filterString.length > 0 + const scrollContainerRef = useRef(null) + + return ( + 59} + getItemKey={(bucket) => bucket.id} + scrollContainerRef={scrollContainerRef} + > + 0} /> + + paddingColSpan={5} + emptyContent={ + showSearchEmptyState ? ( + + ) : undefined + } + trailingContent={ + + } + > + {(bucket) => ( + + )} + + + ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/index.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/index.tsx new file mode 100644 index 00000000000..a4675196516 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/index.tsx @@ -0,0 +1,71 @@ +import { useBreakpoint } from 'common' +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from 'ui' + +import { BucketsPicker } from './BucketsPicker' +import type { AllowedBucketType } from './types' +import type { Bucket } from '@/data/storage/buckets-query' + +export type BucketsPickerDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + acceptedFileExtensions?: string[] + hideUnsupportedFiles?: boolean + onSelect: (bucket: Bucket) => void + allowedBucketType: AllowedBucketType +} + +export function BucketsPickerDialog({ + open, + onOpenChange, + onSelect, + allowedBucketType, +}: BucketsPickerDialogProps) { + const isMobileLayout = useBreakpoint('lg') + + return ( + <> + {isMobileLayout ? ( + + + + + Select a bucket + +
+ +
+
+
+ ) : ( + + + + + Select a bucket + +
+ +
+
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/types.ts b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/types.ts new file mode 100644 index 00000000000..c73379d2ca2 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/types.ts @@ -0,0 +1,7 @@ +export type AllowedBucketType = 'all' | 'public' | 'private' + +export type BucketsTablePaginationProps = { + hasMore?: boolean + isLoadingMore?: boolean + onLoadMore?: () => void +} diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx index 61bb1d2eb72..7b152a46c03 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx @@ -7,7 +7,7 @@ import { BUCKET_TYPES } from './Storage.constants' interface EmptyBucketStateProps { bucketType: keyof typeof BUCKET_TYPES className?: string - onCreateBucket?: () => void + onCreateBucket: () => void } export const EmptyBucketState = ({ diff --git a/apps/studio/components/interfaces/Storage/NewBucketButton.tsx b/apps/studio/components/interfaces/Storage/NewBucketButton.tsx index 8d088f90377..06f56b37317 100644 --- a/apps/studio/components/interfaces/Storage/NewBucketButton.tsx +++ b/apps/studio/components/interfaces/Storage/NewBucketButton.tsx @@ -8,7 +8,7 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' export const CreateBucketButton = ({ onClick, }: { - onClick?: MouseEventHandler + onClick: MouseEventHandler }) => { const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx index c8a3d53dbd1..43e848734a0 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx @@ -11,6 +11,7 @@ import { import { URL_EXPIRY_DURATION } from '../Storage.constants' import { StorageItem } from '../Storage.types' +import { getPathAlongOpenedFolders } from './StorageExplorer.utils' import { useCopyUrl } from './useCopyUrl' import { useFetchFileUrlQuery } from './useFetchFileUrlQuery' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -22,10 +23,12 @@ import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' const PREVIEW_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB const PreviewFile = ({ item }: { item: StorageItem }) => { - const { projectRef, selectedBucket } = useStorageExplorerStateSnapshot() + const { projectRef, selectedBucket, openedFolders } = useStorageExplorerStateSnapshot() + const folderPath = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false) + const path = [folderPath, item.name].filter(Boolean).join('/') const { data: previewUrl, isPending: isLoading } = useFetchFileUrlQuery({ - file: item, + path, projectRef: projectRef, bucket: selectedBucket, }) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx index 066153c513f..7304736dd5a 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx @@ -1,11 +1,8 @@ import { useQuery } from '@tanstack/react-query' -import { StorageItem } from '../Storage.types' -import { getPathAlongOpenedFolders } from './StorageExplorer.utils' import { getPublicUrlForBucketObject } from '@/data/storage/bucket-object-get-public-url-mutation' import { signBucketObject } from '@/data/storage/bucket-object-sign-mutation' import { Bucket } from '@/data/storage/buckets-query' -import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' import type { ResponseError, UseCustomQueryOptions } from '@/types' const DEFAULT_EXPIRY = 7 * 24 * 60 * 60 // in seconds, default to 1 week @@ -36,23 +33,18 @@ export const fetchFileUrl = async ( } type UseFileUrlQueryVariables = { - file: StorageItem + path: string projectRef: string bucket: Bucket } export const useFetchFileUrlQuery = ( - { file, projectRef, bucket }: UseFileUrlQueryVariables, + { path, projectRef, bucket }: UseFileUrlQueryVariables, { ...options }: UseCustomQueryOptions = {} ) => { - const { openedFolders, selectedBucket } = useStorageExplorerStateSnapshot() - const pathToFile = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false) - const formattedPathToFile = [pathToFile, file?.name].join('/') - return useQuery({ - queryKey: [projectRef, 'buckets', bucket.public, bucket.id, 'file', formattedPathToFile], - queryFn: () => - fetchFileUrl(formattedPathToFile, projectRef, bucket.id, bucket.public, DEFAULT_EXPIRY), + queryKey: [projectRef, 'buckets', bucket.public, bucket.id, 'file', path], + queryFn: () => fetchFileUrl(path, projectRef, bucket.id, bucket.public, DEFAULT_EXPIRY), staleTime: DEFAULT_EXPIRY * 1000, ...options, }) diff --git a/apps/studio/data/storage/bucket-objects-infinite-query.ts b/apps/studio/data/storage/bucket-objects-infinite-query.ts new file mode 100644 index 00000000000..eadaf31a821 --- /dev/null +++ b/apps/studio/data/storage/bucket-objects-infinite-query.ts @@ -0,0 +1,55 @@ +import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import { listBucketObjects, type StorageObject } from './bucket-objects-list-mutation' +import { storageKeys } from './keys' +import type { components } from '@/data/api' +import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types' + +const DEFAULT_LIMIT = 200 + +type StorageObjectsQueryParams = { + projectRef?: string + bucketId?: string + path: string + options?: Omit, 'offset'> +} + +export type StorageObjectsData = StorageObject[] +export type StorageObjectsError = ResponseError + +export const useBucketObjectsInfiniteQuery = ( + { projectRef, bucketId, path, options }: StorageObjectsQueryParams, + { + enabled = true, + ...queryOptions + }: UseCustomInfiniteQueryOptions< + StorageObjectsData, + StorageObjectsError, + InfiniteData, + readonly unknown[], + number + > = {} +) => { + const limit = options?.limit ?? DEFAULT_LIMIT + + return useInfiniteQuery({ + queryKey: storageKeys.objects(projectRef, bucketId, path, options), + queryFn: ({ signal, pageParam }) => + listBucketObjects( + { + projectRef: projectRef!, + bucketId, + path, + options: { ...options, limit, offset: pageParam * limit }, + }, + signal + ) as Promise, + enabled: enabled && !!projectRef && !!bucketId, + initialPageParam: 0, + getNextPageParam(lastPage, pages) { + if (lastPage.length < limit) return undefined + return pages.length + }, + ...queryOptions, + }) +} diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index 1747d8d97c7..70d12059147 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -34,6 +34,26 @@ export const storageKeys = { archive: (projectRef: string | undefined) => ['projects', projectRef, 'archive'] as const, publicBucketsWithSelectPolicies: (projectRef: string | undefined, bucketId: string | undefined) => ['projects', projectRef, 'public-buckets-with-select-policies', bucketId] as const, + objects: ( + projectRef: string | undefined, + bucketId: string | undefined, + path: string, + params: { + limit?: number + search?: string + sortColumn?: string + sortOrder?: string + } = {} + ) => + [ + 'projects', + projectRef, + 'buckets', + bucketId, + 'objects', + ...(path ? [path] : []), + ...(params ? [params] : []), + ] as const, icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) => [projectRef, 'warehouse', warehouse, 'namespaces'] as const, icebergNamespace: ({