Merge pull request #11634 from supabase/chore/storage-explorer-support-custom-expiry-for-signed-url

Chore/storage explorer support custom expiry for signed url
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-01-17 22:41:22 +08:00
commit 5ad367a20f
16 files changed
+552 -403

No files matched your search

@@ -40,10 +40,7 @@ const Header: FC<HeaderProps> = ({ sorts, filters, onAddColumn, onAddRow, header
onAddRow={onAddRow}
/>
)}
<div className="sb-grid-header__inner">
<RefreshButton filters={filters} sorts={sorts} />
{headerActions}
</div>
<div className="sb-grid-header__inner">{headerActions}</div>
</div>
)
}
@@ -77,6 +74,7 @@ const DefaultHeader: FC<DefaultHeaderProps> = ({ sorts, filters, onAddColumn, on
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<RefreshButton filters={filters} sorts={sorts} />
<FilterDropdown />
<SortPopover />
</div>
@@ -158,18 +158,6 @@ const EnterpriseUpdate: FC<Props> = ({
// Last todo to support enterprise billing on dashboard + E2E test
const onConfirmPayment = async () => {
setIsSubmitting(true)
let token = captchaToken
try {
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
}
} catch (error) {
setIsSubmitting(false)
return
}
const payload = {
...formSubscriptionUpdatePayload(
currentSubscription,
@@ -177,6 +177,7 @@ const ProUpgrade: FC<Props> = ({
}
const onConfirmPayment = async () => {
setIsSubmitting(true)
const payload = formSubscriptionUpdatePayload(
currentSubscription,
selectedTier,
@@ -169,6 +169,7 @@ const TeamUpgrade: FC<Props> = ({
message: 'Team Plan is not enabled yet.',
})
}
setIsSubmitting(true)
const payload = formSubscriptionUpdatePayload(
currentSubscription,
selectedTier,
@@ -0,0 +1,73 @@
import { FC } from 'react'
import { Badge, Dropdown, IconLoader, IconMoreVertical, IconTrash } from 'ui'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
import { STORAGE_ROW_STATUS } from 'components/to-be-cleaned/Storage/Storage.constants'
interface Props {
bucket: any
projectRef?: string
isSelected: boolean
onSelectDeleteBucket: (bucket: any) => void
onSelectToggleBucketPublic: (bucket: any) => void
}
const BucketRow: FC<Props> = ({
bucket = {},
projectRef = '',
isSelected = false,
onSelectDeleteBucket = () => {},
onSelectToggleBucketPublic = () => {},
}) => {
return (
<ProductMenuItem
key={bucket.id}
name={
<div className="flex items-center justify-between space-x-2 truncate w-full">
<p className="truncate" title={bucket.name}>
{bucket.name}
</p>
{bucket.public && <Badge color="yellow">Public</Badge>}
</div>
}
url={`/project/${projectRef}/storage/buckets/${bucket.id}`}
isActive={isSelected}
action={
bucket.status === STORAGE_ROW_STATUS.LOADING ? (
<IconLoader className="animate-spin" size={16} strokeWidth={2} />
) : bucket.status === STORAGE_ROW_STATUS.READY ? (
<Dropdown
side="bottom"
align="start"
overlay={[
<Dropdown.Item
key="toggle-private"
onClick={() => onSelectToggleBucketPublic(bucket)}
>
{bucket.public ? 'Make private' : 'Make public'}
</Dropdown.Item>,
<Dropdown.Separator key="bucket-separator" />,
<Dropdown.Item
icon={<IconTrash size="tiny" />}
key="delete-bucket"
onClick={() => onSelectDeleteBucket(bucket)}
>
Delete bucket
</Dropdown.Item>,
]}
>
<IconMoreVertical
className="opacity-0 group-hover:opacity-100"
size="tiny"
strokeWidth={2}
/>
</Dropdown>
) : (
<div />
)
}
/>
)
}
export default BucketRow
@@ -2,28 +2,18 @@ import { FC } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { useStore } from 'hooks'
import {
Badge,
Button,
Dropdown,
Menu,
IconLoader,
IconMoreVertical,
Alert,
IconEdit,
IconTrash,
} from 'ui'
import { useParams } from 'hooks'
import { Button, Menu, IconLoader, Alert, IconEdit } from 'ui'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
import { STORAGE_ROW_STATUS } from 'components/to-be-cleaned/Storage/Storage.constants'
import BucketRow from './BucketRow'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
interface Props {}
const StorageMenu: FC<Props> = () => {
const router = useRouter()
const { ref, bucketId } = router.query
const { ref, bucketId } = useParams()
const page = router.pathname.split('/')[4] as
| undefined
| 'policies'
@@ -31,9 +21,6 @@ const StorageMenu: FC<Props> = () => {
| 'usage'
| 'logs'
const { ui } = useStore()
const projectRef = ui.selectedProject?.ref
const storageExplorerStore = useStorageStore()
const {
loaded,
@@ -98,7 +85,7 @@ const StorageMenu: FC<Props> = () => {
<div className="h-px w-full bg-scale-500"></div>
<div className="">
<Menu.Group title="Configuration" />
<Link href={`/project/${projectRef}/storage/policies`}>
<Link href={`/project/${ref}/storage/policies`}>
<Menu.Item rounded active={page === 'policies'}>
<p className="truncate">Policies</p>
</Menu.Item>
@@ -110,61 +97,3 @@ const StorageMenu: FC<Props> = () => {
}
export default observer(StorageMenu)
const BucketRow = ({
bucket = {},
projectRef = '',
isSelected = false,
onSelectDeleteBucket = () => {},
onSelectToggleBucketPublic = () => {},
}: any) => {
return (
<ProductMenuItem
key={bucket.id}
name={
<div className="flex items-center justify-between space-x-2 truncate w-full">
<p className="truncate" title={bucket.name}>
{bucket.name}
</p>
{bucket.public && <Badge color="yellow">Public</Badge>}
</div>
}
url={`/project/${projectRef}/storage/buckets/${bucket.id}`}
isActive={isSelected}
action={
bucket.status === STORAGE_ROW_STATUS.LOADING ? (
<IconLoader className="animate-spin" size={16} strokeWidth={2} />
) : bucket.status === STORAGE_ROW_STATUS.READY ? (
<Dropdown
side="bottom"
align="start"
overlay={[
<Dropdown.Item
key="toggle-private"
onClick={() => onSelectToggleBucketPublic(bucket)}
>
{bucket.public ? 'Make private' : 'Make public'}
</Dropdown.Item>,
<Dropdown.Separator key="bucket-separator" />,
<Dropdown.Item
icon={<IconTrash size="tiny" />}
key="delete-bucket"
onClick={() => onSelectDeleteBucket(bucket)}
>
Delete bucket
</Dropdown.Item>,
]}
>
<IconMoreVertical
className="opacity-0 group-hover:opacity-100"
size="tiny"
strokeWidth={2}
/>
</Dropdown>
) : (
<div />
)
}
/>
)
}
@@ -1,3 +1,9 @@
export const URL_EXPIRY_DURATION = {
WEEK: 60 * 60 * 24 * 7,
MONTH: 60 * 60 * 24 * 30,
YEAR: 60 * 60 * 24 * 365,
}
export const STORAGE_VIEWS = {
COLUMNS: 'COLUMNS',
LIST: 'LIST',
@@ -0,0 +1,78 @@
import { FC } from 'react'
import { Form, Modal, Input, Button } from 'ui'
import { observer } from 'mobx-react-lite'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
interface Props {}
const CustomExpiryModal: FC<Props> = () => {
const storageExplorerStore = useStorageStore()
const { selectedFileCustomExpiry, setSelectedFileCustomExpiry, copyFileURLToClipboard } =
storageExplorerStore
const visible = selectedFileCustomExpiry !== undefined
const onClose = () => setSelectedFileCustomExpiry(undefined)
return (
<Modal
hideFooter
size="small"
header="Custom expiry for signed URL"
visible={visible}
alignFooter="right"
confirmText="Get URL"
onCancel={() => onClose()}
>
<Form
validateOnBlur
initialValues={{ expiresIn: '' }}
onSubmit={async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
await copyFileURLToClipboard(selectedFileCustomExpiry, values.expiresIn)
setSubmitting(false)
onClose()
}}
validate={(values: any) => {
const errors: any = {}
if (values.expiresIn !== '' && values.expiresIn <= 0)
errors.expiresIn = 'Expiry duration cannot be less than 0'
return errors
}}
>
{({ values, isSubmitting }: { values: any; isSubmitting: boolean }) => (
<>
<div className="py-6">
<Modal.Content>
<Input
disabled={isSubmitting}
type="number"
id="expiresIn"
label="Enter the duration for which the URL will be valid:"
actions={<p className="text-sm text-scale-1000 mr-2">seconds</p>}
/>
</Modal.Content>
</div>
<Modal.Separator />
<Modal.Content>
<div className="flex items-center justify-end space-x-2 pt-1 pb-3">
<Button type="default" onClick={() => onClose()}>
Cancel
</Button>
<Button
disabled={values.expiresIn === '' || isSubmitting}
loading={isSubmitting}
htmlType="submit"
type="primary"
>
Get signed URL
</Button>
</div>
</Modal.Content>
</>
)}
</Form>
</Modal>
)
}
export default observer(CustomExpiryModal)
@@ -1,10 +1,12 @@
import React, { useEffect, useRef } from 'react'
import { STORAGE_VIEWS, CONTEXT_MENU_KEYS } from '../Storage.constants'
import { observer } from 'mobx-react-lite'
import { STORAGE_VIEWS, CONTEXT_MENU_KEYS } from '../Storage.constants'
import ItemContextMenu from './ItemContextMenu'
import FolderContextMenu from './FolderContextMenu'
import ColumnContextMenu from './ColumnContextMenu'
import FileExplorerColumn from './FileExplorerColumn'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
const FileExplorer = ({
view = STORAGE_VIEWS.COLUMNS,
@@ -12,18 +14,7 @@ const FileExplorer = ({
openedFolders = [],
selectedItems = [],
selectedFilePreview = {},
onCheckItem = () => {},
onSelectItemDelete = () => {},
onSelectItemRename = () => {},
onSelectItemMove = () => {},
onSelectFile = () => {},
onRenameFile = () => {},
onFilesUpload = () => {},
onCopyFileURL = () => {},
onDownloadFile = () => {},
onSelectFolder = () => {},
onRenameFolder = () => {},
onCreateFolder = () => {},
onSelectAllItemsInColumn = () => {},
onSelectColumnEmptySpace = () => {},
onSelectCreateFolder = () => {},
@@ -33,6 +24,9 @@ const FileExplorer = ({
onColumnLoadMore = () => {},
}) => {
const fileExplorerRef = useRef(null)
const storageExplorerStore = useStorageStore()
const { setSelectedItemToRename } = storageExplorerStore
useEffect(() => {
if (fileExplorerRef) {
@@ -56,18 +50,11 @@ const FileExplorer = ({
onSelectSort={onChangeSortBy}
onSelectSortByOrder={onChangeSortByOrder}
/>
<ItemContextMenu
id={CONTEXT_MENU_KEYS.STORAGE_ITEM}
onCopyFileURL={onCopyFileURL}
onSelectItemRename={onSelectItemRename}
onSelectItemMove={onSelectItemMove}
onDownloadFile={onDownloadFile}
onSelectItemDelete={onSelectItemDelete}
/>
<ItemContextMenu id={CONTEXT_MENU_KEYS.STORAGE_ITEM} />
<FolderContextMenu
id={CONTEXT_MENU_KEYS.STORAGE_FOLDER}
onRenameFolder={onSelectItemRename}
onDeleteFolder={onSelectItemDelete}
onRenameFolder={(folder) => setSelectedItemToRename(folder)}
onDeleteFolder={(folder) => setSelectedItemsToDelete([folder])}
/>
{view === STORAGE_VIEWS.COLUMNS ? (
<div className="flex">
@@ -80,18 +67,7 @@ const FileExplorer = ({
openedFolders={openedFolders}
selectedItems={selectedItems}
selectedFilePreview={selectedFilePreview}
onCheckItem={onCheckItem}
onSelectFile={onSelectFile}
onRenameFile={onRenameFile}
onCopyFileURL={onCopyFileURL}
onFilesUpload={onFilesUpload}
onDownloadFile={onDownloadFile}
onSelectFolder={onSelectFolder}
onRenameFolder={onRenameFolder}
onCreateFolder={onCreateFolder}
onSelectItemDelete={onSelectItemDelete}
onSelectItemRename={onSelectItemRename}
onSelectItemMove={onSelectItemMove}
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
onSelectColumnEmptySpace={onSelectColumnEmptySpace}
onColumnLoadMore={onColumnLoadMore}
@@ -108,18 +84,7 @@ const FileExplorer = ({
column={columns[columns.length - 1]}
selectedItems={selectedItems}
selectedFilePreview={selectedFilePreview}
onCheckItem={onCheckItem}
onSelectFile={onSelectFile}
onRenameFile={onRenameFile}
onCopyFileURL={onCopyFileURL}
onFilesUpload={onFilesUpload}
onDownloadFile={onDownloadFile}
onSelectFolder={onSelectFolder}
onRenameFolder={onRenameFolder}
onCreateFolder={onCreateFolder}
onSelectItemDelete={onSelectItemDelete}
onSelectItemRename={onSelectItemRename}
onSelectItemMove={onSelectItemMove}
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
onSelectColumnEmptySpace={onSelectColumnEmptySpace}
onColumnLoadMore={onColumnLoadMore}
@@ -133,4 +98,4 @@ const FileExplorer = ({
)
}
export default FileExplorer
export default observer(FileExplorer)
@@ -57,18 +57,7 @@ const FileExplorerColumn = ({
openedFolders = [],
selectedItems = [],
selectedFilePreview = {},
onCheckItem = () => {},
onSelectItemDelete = () => {},
onSelectItemRename = () => {},
onSelectItemMove = () => {},
onSelectFile = () => {},
onRenameFile = () => {},
onCopyFileURL = () => {},
onFilesUpload = () => {},
onDownloadFile = () => {},
onSelectFolder = () => {},
onRenameFolder = () => {},
onCreateFolder = () => {},
onSelectAllItemsInColumn = () => {},
onSelectColumnEmptySpace = () => {},
onColumnLoadMore = () => {},
@@ -212,17 +201,15 @@ const FileExplorerColumn = ({
selectedItems,
openedFolders,
selectedFilePreview,
onCheckItem,
onSelectFile,
onRenameFile,
onCopyFileURL,
onDownloadFile,
onSelectFolder,
onRenameFolder,
onCreateFolder,
onSelectItemDelete,
onSelectItemRename,
onSelectItemMove,
// onCheckItem,
// onRenameFile,
// onCopyFileURL,
// onDownloadFile,
// onRenameFolder,
// onCreateFolder,
// onSelectItemDelete,
// onSelectItemRename,
// onSelectItemMove,
}}
ItemComponent={FileExplorerRow}
getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { FC, useState, useRef, useEffect } from 'react'
import { find, has, isEmpty, isEqual } from 'lodash'
import {
Checkbox,
@@ -11,10 +11,11 @@ import {
IconFile,
IconAlertCircle,
IconDownload,
IconTrash,
IconCopy,
IconEdit,
IconMove,
IconClipboard,
IconTrash2,
IconChevronRight,
} from 'ui'
import SVG from 'react-inlinesvg'
import * as Tooltip from '@radix-ui/react-tooltip'
@@ -24,11 +25,12 @@ import {
STORAGE_ROW_TYPES,
STORAGE_ROW_STATUS,
CONTEXT_MENU_KEYS,
} from '../Storage.constants.ts'
URL_EXPIRY_DURATION,
} from '../Storage.constants'
import { formatBytes } from 'lib/helpers'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
const RowIcon = ({ view, status, fileType, mimeType }) => {
const RowIcon = ({ view, status, fileType, mimeType }: any) => {
if (view === STORAGE_VIEWS.LIST && status === STORAGE_ROW_STATUS.LOADING) {
return <IconLoader size={16} strokeWidth={2} className="animate-spin" />
}
@@ -43,7 +45,6 @@ const RowIcon = ({ view, status, fileType, mimeType }) => {
return (
<SVG
src={iconSrc}
alt={fileType}
preProcessor={(code) =>
code.replace(/svg/, 'svg class="w-4 h-4 text-color-inherit opacity-75"')
}
@@ -66,28 +67,44 @@ const RowIcon = ({ view, status, fileType, mimeType }) => {
return <IconFile size={16} strokeWidth={2} />
}
const FileExplorerRow = ({
interface Props {
item: any
view: string
columnIndex: number
selectedItems: any[]
openedFolders: any[]
selectedFilePreview: any
}
const FileExplorerRow: FC<Props> = ({
item = {},
view = STORAGE_VIEWS.COLUMNS,
columnIndex = 0,
selectedItems = [],
openedFolders = [],
selectedFilePreview = {},
onCheckItem = () => {},
onSelectFile = () => {},
onRenameFile = () => {},
onCopyFileURL = () => {},
onDownloadFile = () => {},
onSelectFolder = () => {},
onRenameFolder = () => {},
onCreateFolder = () => {},
onSelectItemDelete = () => {},
onSelectItemRename = () => {},
onSelectItemMove = () => {},
}) => {
const storageExplorerStore = useStorageStore()
const { downloadFolder } = storageExplorerStore
const {
popColumnAtIndex,
pushOpenedFolderAtIndex,
popOpenedFoldersAtIndex,
setFilePreview,
closeFilePreview,
clearSelectedItems,
addNewFolder,
renameFolder,
renameFile,
setSelectedItems,
setSelectedItemsToDelete,
setSelectedItemToRename,
setSelectedItemsToMove,
setSelectedFileCustomExpiry,
fetchFolderContents,
downloadFile,
downloadFolder,
copyFileURLToClipboard,
} = storageExplorerStore
const itemWithColumnIndex = { ...item, columnIndex }
const isSelected = find(selectedItems, item) !== undefined
@@ -95,25 +112,47 @@ const FileExplorerRow = ({
openedFolders.length > columnIndex ? isEqual(openedFolders[columnIndex], item) : false
const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview.id, item.id)
const onSelectFile = async (columnIndex: number, file: any) => {
popColumnAtIndex(columnIndex)
popOpenedFoldersAtIndex(columnIndex - 1)
setFilePreview(file)
clearSelectedItems()
}
const onSelectFolder = async (columnIndex: number, folder: any) => {
closeFilePreview()
clearSelectedItems(columnIndex + 1)
popOpenedFoldersAtIndex(columnIndex - 1)
pushOpenedFolderAtIndex(folder, columnIndex)
await fetchFolderContents(folder.id, folder.name, columnIndex)
}
const onCheckItem = (item: any) => {
if (find(selectedItems, item) === undefined) {
setSelectedItems(selectedItems.concat([item]))
} else {
setSelectedItems(selectedItems.filter((selectedItem: any) => item.id !== selectedItem.id))
}
closeFilePreview()
}
if (item.status === STORAGE_ROW_STATUS.EDITING) {
const inputRef = useRef(null)
const inputRef = useRef<any>(null)
const [itemName, setItemName] = useState(item.name)
useEffect(() => {
if (inputRef.current) {
inputRef.current.select()
}
if (inputRef.current) inputRef.current.select()
}, [])
const onSetItemName = (event) => {
const onSetItemName = async (event: any) => {
event.preventDefault()
event.stopPropagation()
if (item.type === STORAGE_ROW_TYPES.FILE) {
onRenameFile(item, itemName, columnIndex)
await renameFile(item, itemName, columnIndex)
} else if (has(item, 'id')) {
onRenameFolder(itemWithColumnIndex, itemName, columnIndex)
renameFolder(itemWithColumnIndex, itemName, columnIndex)
} else {
onCreateFolder(itemName, columnIndex)
addNewFolder(itemName, columnIndex)
}
}
@@ -146,57 +185,78 @@ const FileExplorerRow = ({
}
const rowOptions =
item.type === STORAGE_ROW_TYPES.BUCKET
? [{ name: 'Delete', onClick: () => onSelectItemDelete(itemWithColumnIndex) }]
: item.type === STORAGE_ROW_TYPES.FOLDER
item.type === STORAGE_ROW_TYPES.FOLDER
? [
{
name: 'Rename',
icon: <IconEdit size="tiny" />,
onClick: () => onSelectItemRename(itemWithColumnIndex),
onClick: () => setSelectedItemToRename(itemWithColumnIndex),
},
{
name: 'Download',
icon: <IconDownload size="tiny" />,
onClick: () => downloadFolder(itemWithColumnIndex),
},
{ name: 'Separator' },
{ name: 'Separator', icon: undefined, onClick: undefined },
{
name: 'Delete',
icon: <IconTrash size="tiny" />,
onClick: () => onSelectItemDelete(itemWithColumnIndex),
icon: <IconTrash2 size="tiny" />,
onClick: () => setSelectedItemsToDelete([itemWithColumnIndex]),
},
]
: [
...(!item.isCorrupted
? [
{
name: 'Copy URL',
icon: <IconCopy size="tiny" />,
onClick: () => onCopyFileURL(itemWithColumnIndex),
name: 'Get URL',
icon: <IconClipboard size="tiny" />,
children: [
{
name: 'Expire in 1 week',
onClick: async () =>
await copyFileURLToClipboard(itemWithColumnIndex, URL_EXPIRY_DURATION.WEEK),
},
{
name: 'Expire in 1 month',
onClick: async () =>
await copyFileURLToClipboard(
itemWithColumnIndex,
URL_EXPIRY_DURATION.MONTH
),
},
{
name: 'Expire in 1 year',
onClick: async () =>
await copyFileURLToClipboard(itemWithColumnIndex, URL_EXPIRY_DURATION.YEAR),
},
{
name: 'Custom expiry',
onClick: async () => setSelectedFileCustomExpiry(itemWithColumnIndex),
},
],
},
{
name: 'Rename',
icon: <IconEdit size="tiny" />,
onClick: () => onSelectItemRename(itemWithColumnIndex),
onClick: () => setSelectedItemToRename(itemWithColumnIndex),
},
{
name: 'Move',
icon: <IconMove size="tiny" />,
onClick: () => onSelectItemMove(itemWithColumnIndex),
onClick: () => setSelectedItemsToMove([itemWithColumnIndex]),
},
{
name: 'Download',
icon: <IconDownload size="tiny" />,
onClick: () => onDownloadFile(itemWithColumnIndex),
onClick: async () => await downloadFile(itemWithColumnIndex),
},
{ name: 'Separator' },
{ name: 'Separator', icon: undefined, onClick: undefined },
]
: []),
{
name: 'Delete',
icon: <IconTrash size="tiny" />,
onClick: () => onSelectItemDelete(itemWithColumnIndex),
icon: <IconTrash2 size="tiny" />,
onClick: () => setSelectedItemsToDelete([itemWithColumnIndex]),
},
]
@@ -206,7 +266,7 @@ const FileExplorerRow = ({
const updatedAt = item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'
const { show } = useContextMenu()
const displayMenu = (event, rowType) => {
const displayMenu = (event: any, rowType: any) => {
show(event, {
id:
rowType === STORAGE_ROW_TYPES.FILE
@@ -342,7 +402,36 @@ const FileExplorerRow = ({
align="end"
overlay={[
rowOptions.map((option) => {
if (option.name === 'Separator') {
if ((option?.children ?? []).length > 0) {
return (
<Dropdown
isNested
key={option.name}
side="right"
align="start"
overlay={(option?.children ?? [])?.map((child) => {
return (
<Dropdown.Item key={child.name} onClick={child.onClick}>
{child.name}
</Dropdown.Item>
)
})}
>
<div
className={[
'flex items-center justify-between px-4 py-1.5 text-xs text-scale-1100',
'w-full focus:bg-scale-300 dark:focus:bg-scale-500 focus:text-scale-1200',
].join(' ')}
>
<div className="flex items-center space-x-2">
{option.icon}
<p>{option.name}</p>
</div>
<IconChevronRight size="tiny" />
</div>
</Dropdown>
)
} else if (option.name === 'Separator') {
return <Dropdown.Separator key="row-separator" />
} else {
return (
@@ -1,43 +1,97 @@
import { Menu, Item } from 'react-contexify'
import { observer } from 'mobx-react-lite'
import { Menu, Item, Separator, Submenu } from 'react-contexify'
import 'react-contexify/dist/ReactContexify.css'
import { IconClipboard, IconEdit, IconMove, IconDownload, IconTrash2, IconChevronRight } from 'ui'
import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
const ItemContextMenu = ({ id = '' }) => {
const storageExplorerStore = useStorageStore()
const {
downloadFile,
selectedBucket,
setSelectedItemsToDelete,
setSelectedItemToRename,
setSelectedItemsToMove,
setSelectedFileCustomExpiry,
copyFileURLToClipboard,
} = storageExplorerStore
const isPublic = selectedBucket.public
const onHandleClick = async (event, item, expiresIn) => {
if (item.isCorrupted) return
switch (event) {
case 'copy':
if (expiresIn < 0) return setSelectedFileCustomExpiry(item)
else return await copyFileURLToClipboard(item, expiresIn)
case 'rename':
return setSelectedItemToRename(item)
case 'move':
return setSelectedItemsToMove([item])
case 'download':
return await downloadFile(item)
default:
break
}
}
const ItemContextMenu = ({
id = '',
onCopyFileURL = () => {},
onSelectItemRename = () => {},
onSelectItemMove = () => {},
onDownloadFile = () => {},
onSelectItemDelete = () => {},
}) => {
return (
<Menu id={id} animation="fade" className="!bg-scale-300 border border-scale-500">
<Item
onClick={({ props }) => (!props.item.isCorrupted ? onCopyFileURL(props.item) : () => {})}
>
<span className="text-xs">Copy URL</span>
{isPublic ? (
<Item onClick={({ props }) => onHandleClick('copy', props.item)}>
<IconClipboard size="tiny" />
<span className="ml-2 text-xs">Get URL</span>
</Item>
) : (
<Submenu
label={
<div className="flex items-center space-x-2">
<IconClipboard size="tiny" />
<span className="text-xs">Get URL</span>
</div>
}
arrow={<IconChevronRight size="tiny" />}
>
<Item
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.WEEK)}
>
<span className="ml-2 text-xs">Expire in 1 week</span>
</Item>
<Item
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.MONTH)}
>
<span className="ml-2 text-xs">Expire in 1 month</span>
</Item>
<Item
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.YEAR)}
>
<span className="ml-2 text-xs">Expire in 1 year</span>
</Item>
<Item onClick={({ props }) => onHandleClick('copy', props.item, -1)}>
<span className="ml-2 text-xs">Custom expiry</span>
</Item>
</Submenu>
)}
<Item onClick={({ props }) => onHandleClick('rename', props.item)}>
<IconEdit size="tiny" />
<span className="ml-2 text-xs">Rename</span>
</Item>
<Item
onClick={({ props }) =>
!props.item.isCorrupted ? onSelectItemRename(props.item) : () => {}
}
>
<span className="text-xs">Rename</span>
<Item onClick={({ props }) => onHandleClick('move', props.item)}>
<IconMove size="tiny" />
<span className="ml-2 text-xs">Move</span>
</Item>
<Item
onClick={({ props }) => (!props.item.isCorrupted ? onSelectItemMove(props.item) : () => {})}
>
<span className="text-xs">Move</span>
<Item onClick={({ props }) => onHandleClick('download', props.item)}>
<IconDownload size="tiny" />
<span className="ml-2 text-xs">Download</span>
</Item>
<Item
onClick={({ props }) => (!props.item.isCorrupted ? onDownloadFile(props.item) : () => {})}
>
<span className="text-xs">Download</span>
</Item>
<Item onClick={({ props }) => onSelectItemDelete(props.item)}>
<span className="text-xs">Delete</span>
<Separator />
<Item onClick={({ props }) => setSelectedItemsToDelete([props.item])}>
<IconTrash2 size="tiny" />
<span className="ml-2 text-xs">Delete</span>
</Item>
</Menu>
)
}
export default ItemContextMenu
export default observer(ItemContextMenu)
@@ -1,17 +1,20 @@
import Link from 'next/link'
import { isEmpty } from 'lodash'
import {
Button,
Dropdown,
IconX,
IconLoader,
IconClipboard,
IconDownload,
IconTrash2,
IconAlertCircle,
IconChevronDown,
} from 'ui'
import SVG from 'react-inlinesvg'
import { formatBytes } from 'lib/helpers'
import { Transition } from '@headlessui/react'
import Link from 'next/link'
import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
const PreviewFile = ({ mimeType, previewUrl }: { mimeType: string; previewUrl: string }) => {
@@ -90,10 +93,12 @@ const PreviewFile = ({ mimeType, previewUrl }: { mimeType: string; previewUrl: s
const PreviewPane = () => {
const storageExplorerStore = useStorageStore()
const {
selectedBucket,
selectedFilePreview: file,
copyFileURLToClipboard,
closeFilePreview,
setSelectedItemsToDelete,
setSelectedFileCustomExpiry,
} = storageExplorerStore
const width = 450
@@ -104,104 +109,148 @@ const PreviewPane = () => {
const updatedAt = file.updated_at ? new Date(file.updated_at).toLocaleString() : 'Unknown'
return (
<Transition
show={isOpen}
enter="transition ease-out duration-150"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in duration-100"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<div
className="
<>
<Transition
show={isOpen}
enter="transition ease-out duration-150"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in duration-100"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<div
className="
h-full border-l
border-panel-border-light bg-panel-header-light p-4 dark:border-panel-border-dark dark:bg-panel-header-dark"
style={{ width }}
>
{/* Preview Header */}
<div className="flex w-full justify-end text-scale-900 transition-colors hover:text-scale-1200">
<IconX
className="cursor-pointer"
size={14}
strokeWidth={2}
onClick={() => closeFilePreview()}
/>
</div>
{/* Preview Thumbnail*/}
<div className="my-4 border border-panel-border-light dark:border-panel-border-dark">
<div className="flex h-56 w-full items-center 2xl:h-72">
<PreviewFile mimeType={mimeType} previewUrl={file.previewUrl} />
style={{ width }}
>
{/* Preview Header */}
<div className="flex w-full justify-end text-scale-900 transition-colors hover:text-scale-1200">
<IconX
className="cursor-pointer"
size={14}
strokeWidth={2}
onClick={() => closeFilePreview()}
/>
</div>
</div>
<div className="w-full space-y-6">
{/* Preview Information */}
<div className="space-y-1">
<h5 className="break-words text-base text-scale-1200">{file.name}</h5>
{file.isCorrupted && (
<div className="flex items-center space-x-2">
<IconAlertCircle size={14} strokeWidth={2} className="text-scale-1100" />
{/* Preview Thumbnail*/}
<div className="my-4 border border-panel-border-light dark:border-panel-border-dark">
<div className="flex h-56 w-full items-center 2xl:h-72">
<PreviewFile mimeType={mimeType} previewUrl={file.previewUrl} />
</div>
</div>
<div className="w-full space-y-6">
{/* Preview Information */}
<div className="space-y-1">
<h5 className="break-words text-base text-scale-1200">{file.name}</h5>
{file.isCorrupted && (
<div className="flex items-center space-x-2">
<IconAlertCircle size={14} strokeWidth={2} className="text-scale-1100" />
<p className="text-sm text-scale-1100">
File is corrupted, please delete and reupload this file again
</p>
</div>
)}
{mimeType && (
<p className="text-sm text-scale-1100">
File is corrupted, please delete and reupload this file again
{mimeType}
{size && <span> - {size}</span>}
</p>
)}
</div>
{/* Preview Metadata */}
<div className="space-y-2">
<div>
<label className="mb-1 text-xs text-scale-900">Added on</label>
<p className="text-sm text-scale-1100">{createdAt}</p>
</div>
<div>
<label className="mb-1 text-xs text-scale-900">Last modified</label>
<p className="text-sm text-scale-1100">{updatedAt}</p>
</div>
)}
{mimeType && (
<p className="text-sm text-scale-1100">
{mimeType}
{size && <span> - {size}</span>}
</p>
)}
</div>
{/* Preview Metadata */}
<div className="space-y-2">
<div>
<label className="mb-1 text-xs text-scale-900">Added on</label>
<p className="text-sm text-scale-1100">{createdAt}</p>
</div>
<div>
<label className="mb-1 text-xs text-scale-900">Last modified</label>
<p className="text-sm text-scale-1100">{updatedAt}</p>
</div>
</div>
{/* Actions */}
<div className="flex space-x-2 border-b border-panel-border-light pb-4 dark:border-panel-border-dark">
<Link href={`${file.previewUrl}&download`}>
<a>
{/* Actions */}
<div className="flex space-x-2 border-b border-panel-border-light pb-4 dark:border-panel-border-dark">
<Link href={`${file.previewUrl}&download`}>
<a>
<Button
type="default"
icon={<IconDownload size={16} strokeWidth={2} />}
disabled={file.isCorrupted}
>
Download
</Button>
</a>
</Link>
{selectedBucket.public ? (
<Button
type="default"
icon={<IconDownload size={16} strokeWidth={2} />}
type="outline"
icon={<IconClipboard size={16} strokeWidth={2} />}
onClick={async () => await copyFileURLToClipboard(file)}
disabled={file.isCorrupted}
>
Download
Get URL
</Button>
</a>
</Link>
) : (
<Dropdown
side="bottom"
align="center"
overlay={[
<Dropdown.Item
onClick={async () =>
await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.WEEK)
}
>
Expire in 1 week
</Dropdown.Item>,
<Dropdown.Item
onClick={async () =>
await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.MONTH)
}
>
Expire in 1 month
</Dropdown.Item>,
<Dropdown.Item
onClick={async () =>
await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.YEAR)
}
>
Expire in 1 year
</Dropdown.Item>,
<Dropdown.Item onClick={() => setSelectedFileCustomExpiry(file)}>
Custom expiry
</Dropdown.Item>,
]}
>
<Button
type="outline"
icon={<IconClipboard size={16} strokeWidth={2} />}
iconRight={<IconChevronDown />}
disabled={file.isCorrupted}
>
Get URL
</Button>
</Dropdown>
)}
</div>
<Button
type="outline"
icon={<IconClipboard size={16} strokeWidth={2} />}
onClick={async () => await copyFileURLToClipboard(file)}
disabled={file.isCorrupted}
shadow={false}
size="tiny"
icon={<IconTrash2 size={16} strokeWidth={2} />}
onClick={() => setSelectedItemsToDelete([file])}
>
Copy URL
Delete file
</Button>
</div>
<Button
type="outline"
shadow={false}
size="tiny"
icon={<IconTrash2 size={16} strokeWidth={2} />}
onClick={() => setSelectedItemsToDelete([file])}
>
Delete file
</Button>
</div>
</div>
</Transition>
</Transition>
</>
)
}
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from 'react'
import { observer } from 'mobx-react-lite'
import { compact, find, isEmpty, uniqBy, get } from 'lodash'
import { compact, isEmpty, uniqBy, get } from 'lodash'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { STORAGE_ROW_TYPES } from '../Storage.constants'
@@ -11,47 +11,38 @@ import FileExplorerHeaderSelection from './FileExplorerHeaderSelection'
import ConfirmDeleteModal from './ConfirmDeleteModal'
import MoveItemsModal from './MoveItemsModal'
import PreviewPane from './PreviewPane'
import CustomExpiryModal from './CustomExpiryModal'
const StorageExplorer = observer(({ bucket }) => {
const storageExplorerStore = useStorageStore()
const {
columns,
selectedFilePreview,
setFilePreview,
closeFilePreview,
selectedItems,
setSelectedItems,
clearSelectedItems,
selectedItemsToDelete,
setSelectedItemsToDelete,
clearSelectedItemsToDelete,
openedFolders,
pushOpenedFolderAtIndex,
popColumnAtIndex,
popOpenedFoldersAtIndex,
setSelectedItemToRename,
selectedItemsToMove,
setSelectedItemsToMove,
clearSelectedItemsToMove,
view,
setView,
setSortBy,
setSortByOrder,
currentBucketName,
copyFileURLToClipboard,
openBucket,
loadExplorerPreferences,
addNewFolderPlaceholder,
addNewFolder,
fetchFolderContents,
fetchMoreFolderContents,
renameFolder,
deleteFolder,
uploadFiles,
deleteFiles,
downloadFile,
renameFile,
moveFiles,
} = storageExplorerStore
@@ -100,35 +91,9 @@ const StorageExplorer = observer(({ bucket }) => {
openBucket(bucket)
}, [bucket])
/** Navigation methods */
const onSelectFile = async (columnIndex, file) => {
popColumnAtIndex(columnIndex)
popOpenedFoldersAtIndex(columnIndex - 1)
setFilePreview(file)
clearSelectedItems()
}
const onSelectFolder = async (columnIndex, folder) => {
closeFilePreview()
clearSelectedItems(columnIndex + 1)
popOpenedFoldersAtIndex(columnIndex - 1)
pushOpenedFolderAtIndex(folder, columnIndex)
await fetchFolderContents(folder.id, folder.name, columnIndex)
}
/** Checkbox selection methods */
/** [Joshen] We'll only support checkbox selection for files ONLY */
const onCheckItem = (item) => {
if (find(selectedItems, item) === undefined) {
setSelectedItems(selectedItems.concat([item]))
} else {
setSelectedItems(selectedItems.filter((selectedItem) => item.id !== selectedItem.id))
}
closeFilePreview()
}
const onSelectAllItemsInColumn = (columnIndex) => {
const columnFiles = columns[columnIndex].items
.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
@@ -155,38 +120,6 @@ const StorageExplorer = observer(({ bucket }) => {
addNewFolderPlaceholder(columnIndex)
}
const onCreateFolder = (folderName, columnIndex) => {
addNewFolder(folderName, columnIndex)
}
const onRenameFolder = (folder, newName, columnIndex) => {
renameFolder(folder, newName, columnIndex)
}
const onSelectItemDelete = (file) => {
setSelectedItemsToDelete([file])
}
const onSelectItemRename = (file) => {
setSelectedItemToRename(file)
}
const onSelectItemMove = (file) => {
setSelectedItemsToMove([file])
}
const onCopyFileURL = async (file) => {
await copyFileURLToClipboard(file)
}
const onDownloadFile = async (file) => {
await downloadFile(file)
}
const onRenameFile = async (file, newName, columnIndex) => {
await renameFile(file, newName, columnIndex)
}
const onFilesUpload = async (event, columnIndex = -1) => {
event.persist()
const items = event.target.files || event.dataTransfer.items
@@ -255,18 +188,7 @@ const StorageExplorer = observer(({ bucket }) => {
openedFolders={openedFolders}
selectedItems={selectedItems}
selectedFilePreview={selectedFilePreview}
onCheckItem={onCheckItem}
onSelectFile={onSelectFile}
onRenameFile={onRenameFile}
onFilesUpload={onFilesUpload}
onCopyFileURL={onCopyFileURL}
onDownloadFile={onDownloadFile}
onSelectFolder={onSelectFolder}
onRenameFolder={onRenameFolder}
onCreateFolder={onCreateFolder}
onSelectItemDelete={onSelectItemDelete}
onSelectItemRename={onSelectItemRename}
onSelectItemMove={onSelectItemMove}
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
onSelectColumnEmptySpace={onSelectColumnEmptySpace}
onSelectCreateFolder={onSelectCreateFolder}
@@ -292,6 +214,7 @@ const StorageExplorer = observer(({ bucket }) => {
onSelectCancel={clearSelectedItemsToMove}
onSelectMove={onMoveSelectedFiles}
/>
<CustomExpiryModal />
</div>
)
})
@@ -37,7 +37,7 @@ export const useStorageExplorerStore = () => {
const CORRUPTED_THRESHOLD_MS = 15 * 60 * 1000 // 15 minutes
const LIMIT = 200
const OFFSET = 0
const DEFAULT_EXPIRY = 10 * 365 * 24 * 60 * 60 // in seconds, default to 1 year
const DEFAULT_EXPIRY = 10 * 365 * 24 * 60 * 60 // in seconds, default to 10 years
const PREVIEW_SIZE_LIMIT = 10000000 // 10MB
const BATCH_SIZE = 2
const EMPTY_FOLDER_PLACEHOLDER_FILE_NAME = '.emptyFolderPlaceholder'
@@ -58,6 +58,7 @@ class StorageExplorerStore {
selectedItemsToDelete = []
selectedItemsToMove = []
selectedFilePreview = {}
selectedFileCustomExpiry = undefined
DEFAULT_OPTIONS = {
limit: LIMIT,
@@ -300,6 +301,10 @@ class StorageExplorerStore {
this.selectedItemsToMove = []
}
setSelectedFileCustomExpiry = (item) => {
this.selectedFileCustomExpiry = item
}
addNewFolderPlaceholder = (columnIndex) => {
const isPrepend = true
const folderName = 'Untitled folder'
@@ -397,9 +402,9 @@ class StorageExplorerStore {
this.selectedFilePreview = {}
}
copyFileURLToClipboard = async (file) => {
copyFileURLToClipboard = async (file, expiresIn = 0) => {
const filePreview = find(this.filePreviewCache, { id: file.id })
if (filePreview) {
if (filePreview && expiresIn === 0) {
// Already generated signed URL
copyToClipboard(filePreview.url, () => {
this.ui.setNotification({
@@ -410,7 +415,7 @@ class StorageExplorerStore {
})
} else {
// Need to generate signed URL, and might as well save it to cache as well
const signedUrl = await this.fetchFilePreview(file.name)
const signedUrl = await this.fetchFilePreview(file.name, expiresIn)
try {
let formattedUrl = new URL(signedUrl)
@@ -840,7 +845,7 @@ class StorageExplorerStore {
this.clearSelectedItemsToMove()
}
fetchFilePreview = async (fileName) => {
fetchFilePreview = async (fileName, expiresIn = 0) => {
const includeBucket = false
const pathToFile = this.getPathAlongOpenedFolders(includeBucket)
const formattedPathToFile = pathToFile.length > 0 ? `${pathToFile}/${fileName}` : fileName
@@ -857,7 +862,7 @@ class StorageExplorerStore {
const { data, error } = await this.supabaseClient.storage
.from(this.selectedBucket.name)
.createSignedUrl(formattedPathToFile, DEFAULT_EXPIRY)
.createSignedUrl(formattedPathToFile, expiresIn || DEFAULT_EXPIRY)
if (!error) {
return data.signedUrl
+3
View File
@@ -4,6 +4,9 @@
.react-contexify__item__content {
@apply dark:text-white text-sm;
}
.react-contexify__submenu {
@apply dark:bg-gray-300 border;
}
}
.react-contexify__item:not(.react-contexify__item--disabled):hover
> .react-contexify__item__content,