diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx index a1212e54c68..935840c585b 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx @@ -12,7 +12,7 @@ import { useOAuthServerAppCreateMutation } from 'data/oauth-server-apps/oauth-se import { useOAuthServerAppRegenerateSecretMutation } from 'data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation' import { useOAuthServerAppUpdateMutation } from 'data/oauth-server-apps/oauth-server-app-update-mutation' import { DOCS_URL } from 'lib/constants' -import { Plus, Trash2, Upload, X } from 'lucide-react' +import { FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react' import { useEffect, useRef, useState, type ChangeEvent } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -42,6 +42,8 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' +import { StorageFilePicker } from '@/components/interfaces/Storage/StorageExplorer/StorageFilePicker' + interface CreateOrUpdateOAuthAppSheetProps { visible: boolean appToEdit?: OAuthClient @@ -89,6 +91,7 @@ export const CreateOrUpdateOAuthAppSheet = ({ const uploadButtonRef = useRef(null) const [showRegenerateDialog, setShowRegenerateDialog] = useState(false) + const [storagePickerOpen, setStoragePickerOpen] = useState(false) const [logoFile, setLogoFile] = useState() const [logoUrl, setLogoUrl] = useState() const [logoRemoved, setLogoRemoved] = useState(false) @@ -136,6 +139,12 @@ export const CreateOrUpdateOAuthAppSheet = ({ }, }) + useEffect(() => { + if (!visible) { + setStoragePickerOpen(false) + } + }, [visible]) + useEffect(() => { if (visible) { setLogoFile(undefined) @@ -240,14 +249,33 @@ export const CreateOrUpdateOAuthAppSheet = ({ } const handleUploadLogo = () => uploadButtonRef.current?.click() + + const handlePickLogoFromStorage = (uri: string) => { + setLogoFile(undefined) + setLogoUrl(uri) + setLogoRemoved(false) + form.setValue('logo_uri', uri) + } + const handleRemoveLogo = () => { setLogoFile(undefined) setLogoUrl(undefined) setLogoRemoved(true) + form.setValue('logo_uri', '') } return ( <> + {projectRef ? ( + + ) : null} onCancel()}> ( - + -
- -
- - {hasLogo && ( + {!hasLogo && } + +
+ {projectRef ? ( + + ) : null} + {hasLogo && ( +
+
-
diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx index e399b925e4a..e6666a793c3 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx @@ -1,3 +1,5 @@ +import type { KeyboardEventHandler, MouseEventHandler } from 'react' + import { useBucketPolicyCount } from 'components/interfaces/Storage/useBucketPolicyCount' import { VirtualizedTableCell, @@ -73,6 +75,8 @@ type BucketTableRowProps = { bucket: Bucket projectRef: string formattedGlobalUploadLimit: string + /** When set, the row selects the bucket instead of navigating to the bucket page. */ + onSelectBucket?: (bucket: Bucket) => void } export const BucketTableRow = ({ @@ -80,6 +84,7 @@ export const BucketTableRow = ({ bucket, projectRef, formattedGlobalUploadLimit, + onSelectBucket, }: BucketTableRowProps) => { const router = useRouter() const { getPolicyCount } = useBucketPolicyCount() @@ -92,14 +97,39 @@ export const BucketTableRow = ({ router ) + const handleRowActivate: MouseEventHandler = (e) => { + if (onSelectBucket) { + e.preventDefault() + onSelectBucket(bucket) + return + } + handleBucketNavigation(e) + } + + const handleRowAuxClick: MouseEventHandler = (e) => { + if (onSelectBucket) return + handleBucketNavigation(e) + } + + const handleRowKeyDown: KeyboardEventHandler = (e) => { + if (onSelectBucket) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelectBucket(bucket) + } + return + } + handleBucketNavigation(e) + } + return ( diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.tsx index b6e09d93028..b68b9d474cf 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.tsx @@ -13,6 +13,8 @@ type BucketsTableProps = { filterString: string formattedGlobalUploadLimit: string pagination: BucketsTablePaginationProps + /** Picker mode: row click calls this instead of navigating to the bucket. */ + onSelectBucket?: (bucket: Bucket) => void } export const BucketsTable = (props: BucketsTableProps) => { @@ -30,6 +32,7 @@ const BucketsTableUnvirtualized = ({ filterString, formattedGlobalUploadLimit, pagination: { hasMore = false, isLoadingMore = false, onLoadMore }, + onSelectBucket, }: BucketsTableProps) => { const showSearchEmptyState = buckets.length === 0 && filterString.length > 0 @@ -52,6 +55,7 @@ const BucketsTableUnvirtualized = ({ bucket={bucket} projectRef={projectRef} formattedGlobalUploadLimit={formattedGlobalUploadLimit} + onSelectBucket={onSelectBucket} /> )) )} @@ -73,6 +77,7 @@ const BucketsTableVirtualized = ({ filterString, formattedGlobalUploadLimit, pagination: { hasMore = false, isLoadingMore = false, onLoadMore }, + onSelectBucket, }: BucketsTableProps) => { const showSearchEmptyState = buckets.length === 0 && filterString.length > 0 const scrollContainerRef = useRef(null) @@ -109,6 +114,7 @@ const BucketsTableVirtualized = ({ bucket={bucket} projectRef={projectRef} formattedGlobalUploadLimit={formattedGlobalUploadLimit} + onSelectBucket={onSelectBucket} /> )} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorer.tsx index 6f1a3e52cb4..9245584bae2 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorer.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorer.tsx @@ -47,8 +47,9 @@ export const FileExplorer = ({
@@ -60,7 +61,7 @@ export const FileExplorer = ({ column={{ id: '', name: '', items: [], status: STORAGE_ROW_STATUS.LOADING }} /> ) : snap.view === STORAGE_VIEWS.COLUMNS ? ( -
+
{columns.map((column, index) => ( 0 && ( void onFilesUpload: (event: any, columnIndex?: number) => void + variant?: 'default' | 'picker' + /** When true with variant picker, hide column/list switch (list-only). */ + forceListView?: boolean } export const FileExplorerHeader = ({ itemSearchString = '', setItemSearchString = noop, onFilesUpload = noop, -}: FileExplorerHeader) => { + variant = 'default', + forceListView = false, +}: FileExplorerHeaderProps) => { const snap = useStorageExplorerStateSnapshot() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() @@ -179,6 +184,7 @@ export const FileExplorerHeader = ({ const breadcrumbs = columns.map((column) => column.name) const backDisabled = columns.length <= 1 const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + const isPicker = variant === 'picker' useEffect(() => { // [Joshen] Somehow toggle search triggers this despite breadcrumbs @@ -347,105 +353,151 @@ export const FileExplorerHeader = ({ Reload - - - - - - {VIEW_OPTIONS.map((option) => ( - snap.setView(option.key)}> -
-

{option.name}

- {snap.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 && ( - - )} -
-
- ))} -
-
-
-
+ {isPicker && forceListView ? ( + + + + + + + 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 && ( + + )} +
+
+ ))} +
+
+
+
+ ) : ( + + + + + + {VIEW_OPTIONS.map((option) => ( + snap.setView(option.key)}> +
+

{option.name}

+ {snap.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 || breadcrumbs.length === 0} - onClick={onSelectUpload} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateStorage - ? 'You need additional permissions to upload files' - : undefined, - }, - }} - > - Upload files - - } - type="text" - disabled={!canUpdateStorage || breadcrumbs.length === 0} - onClick={() => addNewFolderPlaceholder(-1)} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateStorage - ? 'You need additional permissions to create folders' - : undefined, - }, - }} - > - Create folder - -
+ {!isPicker && ( + <> +
+
+
+ +
+ } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={onSelectUpload} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateStorage + ? 'You need additional permissions to upload files' + : undefined, + }, + }} + > + Upload files + + } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={() => addNewFolderPlaceholder(-1)} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateStorage + ? 'You need additional permissions to create folders' + : undefined, + }, + }} + > + Create folder + +
+ + )}
@@ -481,7 +533,7 @@ export const FileExplorerHeader = ({ )}
- {isNewAPIDocsEnabled && ( + {isNewAPIDocsEnabled && !isPicker && ( <>
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index da0f398924c..3de3581dc77 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react' import type { CSSProperties } from 'react' import { useContextMenu } from 'react-contexify' +import { toast } from 'sonner' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { Checkbox, @@ -47,8 +48,11 @@ import { } from '../Storage.constants' import { StorageItemWithColumn, type StorageItem } from '../Storage.types' import { FileExplorerRowEditing } from './FileExplorerRowEditing' -import { copyPathToFolder } from './StorageExplorer.utils' +import { useStorageExplorerPicker } from './StorageExplorerPickerContext' +import { copyPathToFolder, getPathAlongFoldersToIndex } from './StorageExplorer.utils' import { useCopyUrl } from './useCopyUrl' +import { fetchFileUrl } from './useFetchFileUrlQuery' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' export const RowIcon = ({ view, @@ -110,6 +114,7 @@ export const FileExplorerRow = ({ style, }: FileExplorerRowProps) => { const { + projectRef, selectedBucket, selectedFilePreview, openedFolders, @@ -127,6 +132,8 @@ export const FileExplorerRow = ({ downloadFolder, selectRangeItems, } = useStorageExplorerStateSnapshot() + const picker = useStorageExplorerPicker() + const { hostEndpoint, customEndpoint } = useProjectApiUrl({ projectRef }) const { show } = useContextMenu() const { onCopyUrl } = useCopyUrl() @@ -145,6 +152,37 @@ export const FileExplorerRow = ({ clearSelectedItems() } + const resolveObjectPathForItem = () => { + if (item.path) return item.path + const pathToFile = getPathAlongFoldersToIndex({ openedFolders }, columnIndex) + return pathToFile.length > 0 ? `${pathToFile}/${item.name}` : item.name + } + + const pickFile = async () => { + if (!picker || item.isCorrupted) return + try { + const objectPath = resolveObjectPathForItem() + if (picker.returnValue === 'objectPath') { + picker.onPick(objectPath) + return + } + let url = await fetchFileUrl( + objectPath, + projectRef, + selectedBucket.id, + selectedBucket.public + ) + const isCustomDomainActive = !!customEndpoint + if (isCustomDomainActive && hostEndpoint) { + url = url.replace(hostEndpoint, customEndpoint) + } + picker.onPick(url) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Failed to resolve file URL' + toast.error(message) + } + } + const onCheckItem = (isShiftKeyHeld: boolean) => { // Select a range if shift is held down if (isShiftKeyHeld && selectedItems.length !== 0) { @@ -303,6 +341,10 @@ export const FileExplorerRow = ({ className="h-full border-b border-default" onContextMenu={(event) => { event.stopPropagation() + if (picker) { + event.preventDefault() + return + } item.type === STORAGE_ROW_TYPES.FILE ? displayMenu(event, STORAGE_ROW_TYPES.FILE) : displayMenu(event, STORAGE_ROW_TYPES.FOLDER) @@ -320,10 +362,17 @@ export const FileExplorerRow = ({ onClick={(event) => { event.stopPropagation() event.preventDefault() - if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) { - item.type === STORAGE_ROW_TYPES.FOLDER - ? openFolder(columnIndex, item) - : onSelectFile(columnIndex) + if (item.status === STORAGE_ROW_STATUS.LOADING) return + if (item.type === STORAGE_ROW_TYPES.FOLDER) { + if (!isOpened && !isPreviewed) openFolder(columnIndex, item) + return + } + if (picker) { + void pickFile() + return + } + if (!isOpened && !isPreviewed) { + onSelectFile(columnIndex) } }} > @@ -337,7 +386,7 @@ export const FileExplorerRow = ({ {!isSelected && (
@@ -401,7 +450,7 @@ export const FileExplorerRow = ({ size={14} strokeWidth={2} /> - ) : ( + ) : picker ? null : (
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx index 9de3e77cfba..8b99435ffbc 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx @@ -2,11 +2,11 @@ import { useDebounce } from '@uidotdev/usehooks' import { useParams } from 'common' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import type { Bucket } from 'data/storage/buckets-query' -import { useLatest } from 'hooks/misc/useLatest' import { IS_PLATFORM } from 'lib/constants' import { compact, get, isEmpty, uniqBy } from 'lodash' import { useEffect, useRef, useState } from 'react' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' +import { cn } from 'ui' import { useSelectedBucket } from '../FilesBuckets/useSelectedBucket' import { STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants' @@ -17,9 +17,31 @@ import { FileExplorerHeader } from './FileExplorerHeader' import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection' import { MoveItemsModal } from './MoveItemsModal' import { PreviewPane } from './PreviewPane' +import { + StorageExplorerPickerProvider, + type StoragePickerReturnValue, +} from './StorageExplorerPickerContext' import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' -export const StorageExplorer = () => { +export type StorageExplorerProps = { + variant?: 'default' | 'picker' + pickerReturnValue?: StoragePickerReturnValue + onPickerPick?: (value: string) => void + /** Mobile sheet: list layout only; hides column/list switch (sort remains available). */ + forceListView?: boolean + /** When set (embedded picker), loading gate uses bucket id from store instead of the route. */ + expectedBucketId?: string + className?: string +} + +export const StorageExplorer = ({ + variant = 'default', + pickerReturnValue = 'objectPath', + onPickerPick, + forceListView = false, + expectedBucketId, + className, +}: StorageExplorerProps = {}) => { const { ref, bucketId } = useParams() const storageExplorerRef = useRef(null) const { @@ -43,15 +65,18 @@ export const StorageExplorer = () => { setSelectedItemsToMove, } = useStorageExplorerStateSnapshot() - useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM }) - const { data: bucket, isLoading: isBucketQueryLoading } = useSelectedBucket() + const isPicker = variant === 'picker' - // Detect when transitioning between buckets to avoid showing stale content from the previous bucket. - // This happens because the bucket query and effects that update the store run after the first render. - const isLoading = isBucketQueryLoading || (!!bucketId && bucketId !== selectedBucket.id) + useProjectStorageConfigQuery({ projectRef }, { enabled: IS_PLATFORM && !!projectRef }) + const { data: routeBucket, isLoading: isRouteBucketLoading } = useSelectedBucket() + + const isLoading = isPicker + ? !selectedBucket?.id || + (expectedBucketId !== undefined && expectedBucketId !== selectedBucket.id) + : isRouteBucketLoading || (!!bucketId && bucketId !== selectedBucket.id) + + const bucketForFetch = isPicker ? (selectedBucket as Bucket | undefined) : routeBucket - // This state exists outside of the header because FileExplorerColumn needs to listen to these as well - // Things like showing results from a search filter is "temporary", hence we use react state to manage const [itemSearchString, setItemSearchString] = useState('') const debouncedSearchString = useDebounce(itemSearchString, 500) @@ -92,11 +117,10 @@ export const StorageExplorer = () => { }) useEffect(() => { - if (bucket && projectRef) fetchContents(bucket) - }, [bucket, projectRef, debouncedSearchString, selectedBucket.id, fetchContents]) - - /** Checkbox selection methods */ - /** [Joshen] We'll only support checkbox selection for files ONLY */ + const b = bucketForFetch as Bucket | undefined + if (!projectRef || !b?.id) return + void fetchContents(b) + }, [bucketForFetch, projectRef, debouncedSearchString, selectedBucket.id, view, fetchContents]) const onSelectAllItemsInColumn = (columnIndex: number) => { const columnFiles = columns[columnIndex].items @@ -110,20 +134,16 @@ export const StorageExplorer = () => { ) 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) } } - /** File manipulation methods */ - const onFilesUpload = async (event: any, columnIndex: number = -1) => { event.persist() const items = event.target.files || event.dataTransfer.items @@ -136,7 +156,6 @@ export const StorageExplorer = () => { await moveFiles(newPath) } - /** Misc UI methods */ const onSelectColumnEmptySpace = (columnIndex: number) => { popColumnAtIndex(columnIndex) popOpenedFoldersAtIndex(columnIndex - 1) @@ -144,21 +163,26 @@ export const StorageExplorer = () => { clearSelectedItems() } - return ( + const explorerTree = (
{selectedItems.length === 0 ? ( ) : ( )} -
+
{ fetchMoreFolderContents({ index, column, searchString: itemSearchString }) } /> - + {!isPicker && }
- + {!isPicker && ( + <> + - 0} - selectedItemsToMove={selectedItemsToMove} - onSelectCancel={() => setSelectedItemsToMove([])} - onSelectMove={onMoveSelectedFiles} - /> + 0} + selectedItemsToMove={selectedItemsToMove} + onSelectCancel={() => setSelectedItemsToMove([])} + onSelectMove={onMoveSelectedFiles} + /> - + + + )}
) + + if (isPicker && onPickerPick) { + return ( + + {explorerTree} + + ) + } + + return explorerTree } StorageExplorer.displayName = 'StorageExplorer' diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerPickerContext.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerPickerContext.tsx new file mode 100644 index 00000000000..46af9441731 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerPickerContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, type PropsWithChildren } from 'react' + +export type StoragePickerReturnValue = 'objectPath' | 'publicUrl' + +type StorageExplorerPickerContextValue = { + isPicker: true + returnValue: StoragePickerReturnValue + onPick: (value: string) => void + /** When true, hide column view and related chrome (e.g. mobile sheet). */ + forceListView: boolean +} + +const StorageExplorerPickerContext = createContext(null) + +export function StorageExplorerPickerProvider({ + children, + returnValue, + onPick, + forceListView, +}: PropsWithChildren< + Pick +>) { + const value: StorageExplorerPickerContextValue = { + isPicker: true, + returnValue, + onPick, + forceListView, + } + return ( + {children} + ) +} + +export function useStorageExplorerPicker() { + return useContext(StorageExplorerPickerContext) +} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageFilePicker.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageFilePicker.tsx new file mode 100644 index 00000000000..087192592bb --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageFilePicker.tsx @@ -0,0 +1,305 @@ +import { useDebounce } from '@uidotdev/usehooks' +import { useBreakpoint } from 'common' +import { BucketsTable } from 'components/interfaces/Storage/FilesBuckets/BucketsTable' +import AlertError from 'components/ui/AlertError' +import { InlineLink } from 'components/ui/InlineLink' +import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' +import type { Bucket } from 'data/storage/buckets-query' +import { usePaginatedBucketsQuery } from 'data/storage/buckets-query' +import { IS_PLATFORM } from 'lib/constants' +import { formatBytes } from 'lib/helpers' +import { ChevronLeft, Search } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Button, + Card, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { STORAGE_VIEWS } from '../Storage.constants' +import { StorageExplorer } from './StorageExplorer' +import type { StoragePickerReturnValue } from './StorageExplorerPickerContext' +import { + StorageExplorerEmbeddedStateProvider, + useStorageExplorerStateSnapshot, +} from '@/state/storage-explorer' + +export type StorageFilePickerProps = { + open: boolean + onOpenChange: (open: boolean) => void + projectRef: string + returnValue?: StoragePickerReturnValue + onSelect: (value: string) => void + title?: string +} + +/** Keeps list view when the picker is in mobile layout (sheet). */ +function StoragePickerListViewSync({ enforceList }: { enforceList: boolean }) { + const { setView } = useStorageExplorerStateSnapshot() + + useEffect(() => { + if (enforceList) { + setView(STORAGE_VIEWS.LIST) + } + }, [enforceList, setView]) + + return null +} + +function StorageFilePickerExplorer({ + projectRef, + bucketId, + returnValue, + onSelect, + forceListView, +}: { + projectRef: string + bucketId: string + returnValue: StoragePickerReturnValue + onSelect: (value: string) => void + forceListView: boolean +}) { + return ( + +
+ + +
+
+ ) +} + +function StorageFilePickerBucketsStep({ + projectRef, + open, + onSelectBucket, +}: { + projectRef: string + open: boolean + onSelectBucket: (bucket: Bucket) => void +}) { + const [filterString, setFilterString] = useState('') + const debouncedFilter = useDebounce(filterString, 250) + const normalizedSearch = debouncedFilter.trim() + + const { data: storageConfig } = useProjectStorageConfigQuery( + { projectRef }, + { enabled: IS_PLATFORM && !!projectRef } + ) + const formattedGlobalUploadLimit = formatBytes(storageConfig?.fileSizeLimit ?? 0) + + const { + data: bucketsData, + error: bucketsError, + isError: isErrorBuckets, + isPending: isLoadingBuckets, + isSuccess: isSuccessBuckets, + isFetching: isFetchingBuckets, + fetchNextPage, + hasNextPage, + } = usePaginatedBucketsQuery( + { + projectRef, + search: normalizedSearch.length > 0 ? normalizedSearch : undefined, + sortColumn: 'name', + sortOrder: 'asc', + }, + { enabled: !!projectRef } + ) + + const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData]) + const fileBuckets = useMemo( + () => buckets.filter((b) => !('type' in b) || b.type === 'STANDARD'), + [buckets] + ) + const hasNoBuckets = fileBuckets.length === 0 && normalizedSearch.length === 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 needs active API keys to list buckets.{' '} + + Create API keys + + . +

+
+ ) : ( + + )} + + )} + {isSuccessBuckets && ( + <> + {hasNoBuckets ? ( +

+ No file buckets yet.{' '} + Open Storage to + create one. +

+ ) : ( + <> + setFilterString(e.target.value)} + icon={} + /> + + + + + )} + + )} +
+ ) +} + +export function StorageFilePicker({ + open, + onOpenChange, + projectRef, + returnValue = 'objectPath', + onSelect, + title = 'Choose a file', +}: StorageFilePickerProps) { + const isMobileLayout = useBreakpoint('lg') + const [selectedBucket, setSelectedBucket] = useState(null) + + useEffect(() => { + if (!open) { + setSelectedBucket(null) + } + }, [open]) + + const handleSelect = (value: string) => { + onSelect(value) + onOpenChange(false) + } + + const pickerTitle = selectedBucket + ? `${title} — ${selectedBucket.id}` + : `${title} — choose bucket` + + const headerActions = selectedBucket ? ( + + ) : null + + const body = selectedBucket ? ( +
+ +
+ ) : ( + + ) + + if (isMobileLayout) { + return ( + + + + {headerActions} + {pickerTitle} + +
{body}
+
+
+ ) + } + + return ( + + + + {headerActions} + {pickerTitle} + +
{body}
+
+
+ ) +} + +export function useStorageFilePicker() { + const [open, setOpen] = useState(false) + + return { + open, + setOpen, + pickProps: { + open, + onOpenChange: setOpen, + } as const, + } +} diff --git a/apps/studio/data/storage/buckets-query.ts b/apps/studio/data/storage/buckets-query.ts index 15a4efee5be..49989d3442c 100644 --- a/apps/studio/data/storage/buckets-query.ts +++ b/apps/studio/data/storage/buckets-query.ts @@ -11,6 +11,7 @@ import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { PROJECT_STATUS } from 'lib/constants' import { ResponseError, @@ -117,11 +118,21 @@ export type BucketsData = Awaited> export type BucketsWithPaginationData = Awaited> export type BucketsError = ResponseError +type UseBucketQueryOptions = UseCustomQueryOptions & { + /** When set, project health/active checks use this ref (e.g. embedded pickers) instead of the route project. */ + healthCheckProjectRef?: string +} + export const useBucketQuery = ( { projectRef, bucketId }: GetBucketParams, - { enabled = true, ...options }: UseCustomQueryOptions + { enabled = true, healthCheckProjectRef, ...options }: UseBucketQueryOptions = {} ) => { - const { data: project } = useSelectedProjectQuery() + const { data: routeProject } = useSelectedProjectQuery({ enabled: !healthCheckProjectRef }) + const { data: explicitProject } = useProjectDetailQuery( + { ref: healthCheckProjectRef }, + { enabled: !!healthCheckProjectRef } + ) + const project = healthCheckProjectRef ? explicitProject : routeProject const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY return useQuery({ diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 4448c501edf..e84f56c8759 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -45,7 +45,8 @@ import { deleteBucketObject } from '@/data/storage/bucket-object-delete-mutation import { signBucketObjects } from '@/data/storage/bucket-object-sign-mutation' import { listBucketObjects, StorageObject } from '@/data/storage/bucket-objects-list-mutation' import { deleteBucketPrefix } from '@/data/storage/bucket-prefix-delete-mutation' -import type { Bucket } from '@/data/storage/buckets-query' +import { useBucketQuery, type Bucket } from '@/data/storage/buckets-query' +import { useProjectDetailQuery } from '@/data/projects/project-detail-query' import { moveStorageObject } from '@/data/storage/object-move-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { PROJECT_STATUS } from '@/lib/constants' @@ -80,23 +81,37 @@ if (typeof window !== 'undefined') { abortController = new AbortController() } -function createStorageExplorerState({ - projectRef, - connectionString, - bucket, - resumableUploadUrl, - clientEndpoint, -}: { - projectRef: string - connectionString: string - bucket?: Bucket - resumableUploadUrl: string - clientEndpoint: string -}) { +export type CreateStorageExplorerStateOptions = { + /** When false, view/sort preferences are not read from or written to localStorage (e.g. embedded file picker). */ + persistExplorerPreferences?: boolean + initialView?: STORAGE_VIEWS +} + +function createStorageExplorerState( + { + projectRef, + connectionString, + bucket, + resumableUploadUrl, + clientEndpoint, + }: { + projectRef: string + connectionString: string + bucket?: Bucket + resumableUploadUrl: string + clientEndpoint: string + }, + explorerOptions?: CreateStorageExplorerStateOptions +) { + const persistExplorerPreferences = explorerOptions?.persistExplorerPreferences ?? true const localStorageKey = LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef) - const { view, sortBy, sortByOrder, sortBucket } = - (typeof window !== 'undefined' && tryParseJson(localStorage?.getItem(localStorageKey))) || - DEFAULT_PREFERENCES + const storedPrefs = + persistExplorerPreferences && typeof window !== 'undefined' + ? tryParseJson(localStorage?.getItem(localStorageKey)) + : null + const mergedPrefs = storedPrefs || DEFAULT_PREFERENCES + const view = explorerOptions?.initialView ?? mergedPrefs.view + const { sortBy, sortByOrder, sortBucket } = mergedPrefs const state = proxy({ projectRef, @@ -210,12 +225,10 @@ function createStorageExplorerState({ setSelectedFileCustomExpiry: (item?: StorageItem) => (state.selectedFileCustomExpiry = item), updateExplorerPreference: () => { - const localStorageKey = LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef) + if (!persistExplorerPreferences) return + const lsKey = LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef) const { view, sortBy, sortByOrder, sortBucket } = state - localStorage.setItem( - localStorageKey, - JSON.stringify({ view, sortBy, sortByOrder, sortBucket }) - ) + localStorage.setItem(lsKey, JSON.stringify({ view, sortBy, sortByOrder, sortBucket })) }, // Functions that manage the UI of the Storage Explorer @@ -1908,13 +1921,16 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild const resumableUploadUrl = `${clientEndpoint}/storage/v1/upload/resumable` setState( - createStorageExplorerState({ - projectRef: project.ref, - connectionString: project.connectionString ?? '', - bucket, - resumableUploadUrl, - clientEndpoint, - }) + createStorageExplorerState( + { + projectRef: project.ref, + connectionString: project.connectionString ?? '', + bucket, + resumableUploadUrl, + clientEndpoint, + }, + { persistExplorerPreferences: true } + ) ) } }, [ @@ -1935,6 +1951,84 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild ) } +export type StorageExplorerEmbeddedStateProviderProps = PropsWithChildren<{ + projectRef: string + bucketId: string + persistExplorerPreferences?: boolean + initialView?: STORAGE_VIEWS +}> + +/** + * Isolated storage explorer state for embedded UIs (e.g. file picker) that resolves the bucket by id + * without requiring `bucketId` in the route. + */ +export const StorageExplorerEmbeddedStateProvider = ({ + children, + projectRef, + bucketId, + persistExplorerPreferences = false, + initialView, +}: StorageExplorerEmbeddedStateProviderProps) => { + const { data: project } = useProjectDetailQuery( + { ref: projectRef }, + { enabled: !!projectRef } + ) + const isPaused = project?.status === PROJECT_STATUS.INACTIVE + + const { data: bucket } = useBucketQuery( + { projectRef, bucketId }, + { + enabled: !!projectRef && !!bucketId, + healthCheckProjectRef: projectRef, + } + ) + + const [state, setState] = useState(() => createStorageExplorerState(DEFAULT_STATE_CONFIG)) + + const { + storageEndpoint, + hostEndpoint, + isSuccess: isSuccessSettings, + } = useProjectApiUrl({ projectRef: project?.ref }) + + useEffect(() => { + const hasDataReady = !!project && !!bucket + if (!isPaused && hasDataReady && isSuccessSettings) { + const clientEndpoint = storageEndpoint ?? hostEndpoint ?? '' + const resumableUploadUrl = `${clientEndpoint}/storage/v1/upload/resumable` + + setState( + createStorageExplorerState( + { + projectRef: project.ref, + connectionString: project.connectionString ?? '', + bucket, + resumableUploadUrl, + clientEndpoint, + }, + { persistExplorerPreferences, initialView } + ) + ) + } + }, [ + isPaused, + project, + bucket, + bucketId, + isSuccessSettings, + hostEndpoint, + storageEndpoint, + persistExplorerPreferences, + initialView, + ]) + + return ( + + {children} + + ) +} + export function useStorageExplorerStateSnapshot(options?: Parameters[1]) { const state = useContext(StorageExplorerStateContext) return useSnapshot(state, options) as StorageExplorerState