mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
StorageFilePicker
This commit is contained in:
1 parent
adf8b0c67c
commit
0f359ca22d
12 files changed
+883
-213
No files matched your search
@@ -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<HTMLInputElement>(null)
|
||||
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
|
||||
const [storagePickerOpen, setStoragePickerOpen] = useState(false)
|
||||
const [logoFile, setLogoFile] = useState<File>()
|
||||
const [logoUrl, setLogoUrl] = useState<string>()
|
||||
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 ? (
|
||||
<StorageFilePicker
|
||||
open={storagePickerOpen}
|
||||
onOpenChange={setStoragePickerOpen}
|
||||
projectRef={projectRef}
|
||||
returnValue="publicUrl"
|
||||
title="Choose logo from Storage"
|
||||
onSelect={handlePickLogoFromStorage}
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={visible} onOpenChange={() => onCancel()}>
|
||||
<SheetContent
|
||||
size="lg"
|
||||
@@ -293,46 +321,61 @@ export const CreateOrUpdateOAuthAppSheet = ({
|
||||
control={form.control}
|
||||
name="logo_uri"
|
||||
render={() => (
|
||||
<FormItemLayout label="Logo" description="Upload a logo for your OAuth app">
|
||||
<FormItemLayout
|
||||
label="Logo"
|
||||
description="Upload an image, or pick a file from Storage (choose a bucket, then a file). A public URL is used as logo_uri when possible—use a public bucket for a stable logo URL."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<div className="flex gap-4 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadLogo}
|
||||
className={cn(
|
||||
'flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong'
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
|
||||
}}
|
||||
>
|
||||
{!hasLogo && <Upload size={14} />}
|
||||
</button>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Upload size={14} />}
|
||||
<div className="flex w-full max-w-md flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadLogo}
|
||||
className={cn(
|
||||
'flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong'
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
{hasLogo && (
|
||||
{!hasLogo && <Upload size={14} />}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Trash2 size={12} />}
|
||||
onClick={handleRemoveLogo}
|
||||
/>
|
||||
)}
|
||||
icon={<Upload size={14} />}
|
||||
onClick={handleUploadLogo}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
{projectRef ? (
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<FolderOpen size={14} />}
|
||||
onClick={() => setStoragePickerOpen(true)}
|
||||
>
|
||||
Storage
|
||||
</Button>
|
||||
) : null}
|
||||
{hasLogo && (
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Trash2 size={12} />}
|
||||
onClick={handleRemoveLogo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadButtonRef}
|
||||
className="hidden"
|
||||
accept="image/png, image/jpeg"
|
||||
onChange={onFileUpload}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadButtonRef}
|
||||
className="hidden"
|
||||
accept="image/png, image/jpeg"
|
||||
onChange={onFileUpload}
|
||||
/>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
|
||||
@@ -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<HTMLTableRowElement> = (e) => {
|
||||
if (onSelectBucket) {
|
||||
e.preventDefault()
|
||||
onSelectBucket(bucket)
|
||||
return
|
||||
}
|
||||
handleBucketNavigation(e)
|
||||
}
|
||||
|
||||
const handleRowAuxClick: MouseEventHandler<HTMLTableRowElement> = (e) => {
|
||||
if (onSelectBucket) return
|
||||
handleBucketNavigation(e)
|
||||
}
|
||||
|
||||
const handleRowKeyDown: KeyboardEventHandler<HTMLTableRowElement> = (e) => {
|
||||
if (onSelectBucket) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelectBucket(bucket)
|
||||
}
|
||||
return
|
||||
}
|
||||
handleBucketNavigation(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<BucketTableRow
|
||||
key={bucket.id}
|
||||
data-bucket-id={bucket.id}
|
||||
className="relative cursor-pointer h-16 group inset-focus"
|
||||
onClick={handleBucketNavigation}
|
||||
onAuxClick={handleBucketNavigation}
|
||||
onKeyDown={handleBucketNavigation}
|
||||
onClick={handleRowActivate}
|
||||
onAuxClick={handleRowAuxClick}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<BucketTableCell className="w-2 pr-1">
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
@@ -109,6 +114,7 @@ const BucketsTableVirtualized = ({
|
||||
bucket={bucket}
|
||||
projectRef={projectRef}
|
||||
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
|
||||
onSelectBucket={onSelectBucket}
|
||||
/>
|
||||
)}
|
||||
</VirtualizedTableBody>
|
||||
|
||||
@@ -47,8 +47,9 @@ export const FileExplorer = ({
|
||||
<div
|
||||
ref={fileExplorerRef}
|
||||
className={cn(
|
||||
'file-explorer flex flex-grow overflow-x-auto justify-between h-full w-full relative',
|
||||
snap.view === STORAGE_VIEWS.LIST && 'flex-col'
|
||||
'file-explorer relative flex min-h-0 min-w-0 w-full flex-1 justify-between overflow-x-auto',
|
||||
snap.view === STORAGE_VIEWS.LIST && 'h-full min-h-0 flex-col',
|
||||
snap.view === STORAGE_VIEWS.COLUMNS && 'h-full'
|
||||
)}
|
||||
>
|
||||
<ColumnContextMenu id={CONTEXT_MENU_KEYS.STORAGE_COLUMN} />
|
||||
@@ -60,7 +61,7 @@ export const FileExplorer = ({
|
||||
column={{ id: '', name: '', items: [], status: STORAGE_ROW_STATUS.LOADING }}
|
||||
/>
|
||||
) : snap.view === STORAGE_VIEWS.COLUMNS ? (
|
||||
<div className="flex">
|
||||
<div className="flex h-full min-h-0 min-w-0">
|
||||
{columns.map((column, index) => (
|
||||
<FileExplorerColumn
|
||||
key={`column-${index}`}
|
||||
|
||||
@@ -167,9 +167,10 @@ export const FileExplorerColumn = ({
|
||||
<div
|
||||
ref={fileExplorerColumnRef}
|
||||
className={cn(
|
||||
'hide-scrollbar relative flex flex-col',
|
||||
fullWidth ? 'w-full' : 'w-64 border-r border-overlay',
|
||||
snap.view === STORAGE_VIEWS.LIST && 'h-full',
|
||||
'hide-scrollbar relative flex flex-shrink-0 flex-col overflow-auto'
|
||||
snap.view === STORAGE_VIEWS.LIST && 'min-h-0 flex-1 overflow-hidden',
|
||||
snap.view === STORAGE_VIEWS.COLUMNS && 'h-full flex-shrink-0 overflow-auto'
|
||||
)}
|
||||
onContextMenu={displayMenu}
|
||||
onDragOver={onDragOver}
|
||||
@@ -232,7 +233,7 @@ export const FileExplorerColumn = ({
|
||||
{/* Column Interface */}
|
||||
{columnItems.length > 0 && (
|
||||
<InfiniteListDefault
|
||||
className="h-full"
|
||||
className={snap.view === STORAGE_VIEWS.LIST ? 'min-h-0 flex-1' : 'h-full'}
|
||||
items={columnItems}
|
||||
itemProps={itemProps}
|
||||
getItemKey={getItemKey}
|
||||
|
||||
+152
-100
@@ -135,17 +135,22 @@ const HeaderBreadcrumbs = ({
|
||||
)
|
||||
}
|
||||
|
||||
interface FileExplorerHeader {
|
||||
interface FileExplorerHeaderProps {
|
||||
itemSearchString: string
|
||||
setItemSearchString: (value: string) => 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
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
snap.view === 'LIST' ? (
|
||||
<List size={16} strokeWidth={2} />
|
||||
) : (
|
||||
<Columns size={16} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-0">
|
||||
{VIEW_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => snap.setView(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{snap.view === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-44">
|
||||
{SORT_BY_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortBy === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{SORT_ORDER_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => setSortByOrder(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortByOrder === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isPicker && forceListView ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="text" icon={<List size={16} strokeWidth={2} />}>
|
||||
Sort
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44 min-w-0">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-44">
|
||||
{SORT_BY_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortBy === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{SORT_ORDER_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => setSortByOrder(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortByOrder === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
snap.view === 'LIST' ? (
|
||||
<List size={16} strokeWidth={2} />
|
||||
) : (
|
||||
<Columns size={16} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-0">
|
||||
{VIEW_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => snap.setView(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{snap.view === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-44">
|
||||
{SORT_BY_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortBy === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{SORT_ORDER_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.key} onClick={() => setSortByOrder(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortByOrder === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-6 border-r border-control" />
|
||||
<div className="flex items-center space-x-1 px-2">
|
||||
<div className="hidden">
|
||||
<input ref={uploadButtonRef} type="file" multiple onChange={onFilesUpload} />
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
icon={<Upload size={16} strokeWidth={2} />}
|
||||
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
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip
|
||||
icon={<FolderPlus size={16} strokeWidth={2} />}
|
||||
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
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
{!isPicker && (
|
||||
<>
|
||||
<div className="h-6 border-r border-control" />
|
||||
<div className="flex items-center space-x-1 px-2">
|
||||
<div className="hidden">
|
||||
<input ref={uploadButtonRef} type="file" multiple onChange={onFilesUpload} />
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
icon={<Upload size={16} strokeWidth={2} />}
|
||||
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
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip
|
||||
icon={<FolderPlus size={16} strokeWidth={2} />}
|
||||
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
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-6 border-r border-control" />
|
||||
<div className="flex items-center px-2">
|
||||
@@ -481,7 +533,7 @@ export const FileExplorerHeader = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isNewAPIDocsEnabled && (
|
||||
{isNewAPIDocsEnabled && !isPicker && (
|
||||
<>
|
||||
<div className="h-6 border-r border-control" />
|
||||
<div className="mx-2">
|
||||
|
||||
@@ -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 && (
|
||||
<div
|
||||
className={`absolute ${
|
||||
item.type === STORAGE_ROW_TYPES.FILE ? 'group-hover:hidden' : ''
|
||||
item.type === STORAGE_ROW_TYPES.FILE && !picker ? 'group-hover:hidden' : ''
|
||||
}`}
|
||||
style={{ top: '2px' }}
|
||||
>
|
||||
@@ -401,7 +450,7 @@ export const FileExplorerRow = ({
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
) : (
|
||||
) : picker ? null : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="storage-row-menu opacity-0">
|
||||
|
||||
@@ -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 = (
|
||||
<div
|
||||
ref={storageExplorerRef}
|
||||
className="bg-studio border rounded-md border-overlay flex h-full w-full flex-col"
|
||||
className={cn(
|
||||
'bg-studio border border-overlay flex h-full min-h-0 w-full min-w-0 flex-1 flex-col rounded-md',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{selectedItems.length === 0 ? (
|
||||
<FileExplorerHeader
|
||||
itemSearchString={itemSearchString}
|
||||
setItemSearchString={setItemSearchString}
|
||||
onFilesUpload={onFilesUpload}
|
||||
variant={variant}
|
||||
forceListView={forceListView}
|
||||
/>
|
||||
) : (
|
||||
<FileExplorerHeaderSelection />
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="file-explorer flex min-h-0 min-w-0 flex-1">
|
||||
<FileExplorer
|
||||
columns={columns}
|
||||
selectedItems={selectedItems}
|
||||
@@ -171,22 +195,40 @@ export const StorageExplorer = () => {
|
||||
fetchMoreFolderContents({ index, column, searchString: itemSearchString })
|
||||
}
|
||||
/>
|
||||
<PreviewPane />
|
||||
{!isPicker && <PreviewPane />}
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteModal />
|
||||
{!isPicker && (
|
||||
<>
|
||||
<ConfirmDeleteModal />
|
||||
|
||||
<MoveItemsModal
|
||||
bucketName={selectedBucket.name}
|
||||
visible={selectedItemsToMove.length > 0}
|
||||
selectedItemsToMove={selectedItemsToMove}
|
||||
onSelectCancel={() => setSelectedItemsToMove([])}
|
||||
onSelectMove={onMoveSelectedFiles}
|
||||
/>
|
||||
<MoveItemsModal
|
||||
bucketName={selectedBucket.name}
|
||||
visible={selectedItemsToMove.length > 0}
|
||||
selectedItemsToMove={selectedItemsToMove}
|
||||
onSelectCancel={() => setSelectedItemsToMove([])}
|
||||
onSelectMove={onMoveSelectedFiles}
|
||||
/>
|
||||
|
||||
<CustomExpiryModal />
|
||||
<CustomExpiryModal />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isPicker && onPickerPick) {
|
||||
return (
|
||||
<StorageExplorerPickerProvider
|
||||
returnValue={pickerReturnValue}
|
||||
onPick={onPickerPick}
|
||||
forceListView={forceListView}
|
||||
>
|
||||
{explorerTree}
|
||||
</StorageExplorerPickerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return explorerTree
|
||||
}
|
||||
|
||||
StorageExplorer.displayName = 'StorageExplorer'
|
||||
+36
@@ -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<StorageExplorerPickerContextValue | null>(null)
|
||||
|
||||
export function StorageExplorerPickerProvider({
|
||||
children,
|
||||
returnValue,
|
||||
onPick,
|
||||
forceListView,
|
||||
}: PropsWithChildren<
|
||||
Pick<StorageExplorerPickerContextValue, 'returnValue' | 'onPick' | 'forceListView'>
|
||||
>) {
|
||||
const value: StorageExplorerPickerContextValue = {
|
||||
isPicker: true,
|
||||
returnValue,
|
||||
onPick,
|
||||
forceListView,
|
||||
}
|
||||
return (
|
||||
<StorageExplorerPickerContext.Provider value={value}>{children}</StorageExplorerPickerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useStorageExplorerPicker() {
|
||||
return useContext(StorageExplorerPickerContext)
|
||||
}
|
||||
@@ -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 (
|
||||
<StorageExplorerEmbeddedStateProvider
|
||||
projectRef={projectRef}
|
||||
bucketId={bucketId}
|
||||
persistExplorerPreferences={false}
|
||||
initialView={forceListView ? STORAGE_VIEWS.LIST : undefined}
|
||||
>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
<StoragePickerListViewSync enforceList={forceListView} />
|
||||
<StorageExplorer
|
||||
variant="picker"
|
||||
pickerReturnValue={returnValue}
|
||||
onPickerPick={onSelect}
|
||||
forceListView={forceListView}
|
||||
expectedBucketId={bucketId}
|
||||
className="h-full min-h-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</StorageExplorerEmbeddedStateProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-1 flex-col gap-3 h-screen max-h-[600px] min-h-[300px] w-full">
|
||||
{isLoadingBuckets && <GenericSkeletonLoader />}
|
||||
{isErrorBuckets && (
|
||||
<>
|
||||
{hasNoApiKeys ? (
|
||||
<Admonition type="warning" title="Project has no active API keys enabled">
|
||||
<p className="!leading-normal text-sm">
|
||||
The Dashboard needs active API keys to list buckets.{' '}
|
||||
<InlineLink href={`/project/${projectRef}/settings/api-keys/new`}>
|
||||
Create API keys
|
||||
</InlineLink>
|
||||
.
|
||||
</p>
|
||||
</Admonition>
|
||||
) : (
|
||||
<AlertError error={bucketsError} subject="Failed to retrieve buckets" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSuccessBuckets && (
|
||||
<>
|
||||
{hasNoBuckets ? (
|
||||
<p className="text-sm text-foreground-light px-1">
|
||||
No file buckets yet.{' '}
|
||||
<InlineLink href={`/project/${projectRef}/storage/files`}>Open Storage</InlineLink> to
|
||||
create one.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
size="tiny"
|
||||
className="w-full max-w-sm"
|
||||
placeholder="Search buckets"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
icon={<Search />}
|
||||
/>
|
||||
<Card className="min-h-0 flex-1 overflow-hidden">
|
||||
<BucketsTable
|
||||
buckets={fileBuckets}
|
||||
projectRef={projectRef}
|
||||
filterString={filterString}
|
||||
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
|
||||
pagination={{
|
||||
hasMore: hasNextPage,
|
||||
isLoadingMore: isFetchingBuckets,
|
||||
onLoadMore: handleLoadMoreBuckets,
|
||||
}}
|
||||
onSelectBucket={onSelectBucket}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StorageFilePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectRef,
|
||||
returnValue = 'objectPath',
|
||||
onSelect,
|
||||
title = 'Choose a file',
|
||||
}: StorageFilePickerProps) {
|
||||
const isMobileLayout = useBreakpoint('lg')
|
||||
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(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 ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<ChevronLeft size={16} />}
|
||||
onClick={() => setSelectedBucket(null)}
|
||||
className="shrink-0"
|
||||
>
|
||||
Buckets
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const body = selectedBucket ? (
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-col">
|
||||
<StorageFilePickerExplorer
|
||||
projectRef={projectRef}
|
||||
bucketId={selectedBucket.id}
|
||||
returnValue={returnValue}
|
||||
onSelect={handleSelect}
|
||||
forceListView={isMobileLayout}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<StorageFilePickerBucketsStep
|
||||
projectRef={projectRef}
|
||||
open={open}
|
||||
onSelectBucket={setSelectedBucket}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isMobileLayout) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="flex h-[85vh] flex-col gap-0 p-0">
|
||||
<SheetHeader className="flex flex-row items-center gap-2 border-b border-overlay px-4 py-3 text-left">
|
||||
{headerActions}
|
||||
<SheetTitle className="min-w-0 flex-1 text-left">{pickerTitle}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col px-2 pb-3 pt-2">{body}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
size="xxxlarge"
|
||||
className="flex max-h-[85vh] max-w-6xl flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="flex flex-row items-center gap-2 border-b border-overlay px-6 py-4">
|
||||
{headerActions}
|
||||
<DialogTitle className="min-w-0 flex-1">{pickerTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-3">{body}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function useStorageFilePicker() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
pickProps: {
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
} as const,
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<typeof getBuckets>>
|
||||
export type BucketsWithPaginationData = Awaited<ReturnType<typeof getBucketsPaginated>>
|
||||
export type BucketsError = ResponseError
|
||||
|
||||
type UseBucketQueryOptions<TData> = UseCustomQueryOptions<BucketData, BucketsError, TData> & {
|
||||
/** When set, project health/active checks use this ref (e.g. embedded pickers) instead of the route project. */
|
||||
healthCheckProjectRef?: string
|
||||
}
|
||||
|
||||
export const useBucketQuery = <TData = BucketData>(
|
||||
{ projectRef, bucketId }: GetBucketParams,
|
||||
{ enabled = true, ...options }: UseCustomQueryOptions<BucketData, BucketsError, TData>
|
||||
{ enabled = true, healthCheckProjectRef, ...options }: UseBucketQueryOptions<TData> = {}
|
||||
) => {
|
||||
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<BucketData, BucketsError, TData>({
|
||||
|
||||
@@ -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 (
|
||||
<StorageExplorerStateContext.Provider value={state}>
|
||||
{children}
|
||||
</StorageExplorerStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useStorageExplorerStateSnapshot(options?: Parameters<typeof useSnapshot>[1]) {
|
||||
const state = useContext(StorageExplorerStateContext)
|
||||
return useSnapshot(state, options) as StorageExplorerState
|
||||
|
||||
Reference in new issue
Block a user