mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
feat(studio): add bucket versioning, object versions, and deleted files prototype
Scoped-down storage protection prototype with three features: - Bucket-level versioning policy (retention days, max versions) in create/edit modals - Object version history in the file preview pane with restore/delete actions - Per-bucket deleted files (trash) view with bulk selection, restore, and permanent delete All data is mock-backed behind the STORAGE_PROTECTION_ENABLED feature flag. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiUEvmC84bRsteqsWXHY2p
This commit is contained in:
16 files changed
+1481
-135
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { DialogSection, DialogSectionSeparator, Input, Label, Switch } from 'ui'
|
||||
|
||||
import {
|
||||
getMockBucketProtection,
|
||||
PROJECT_VERSIONING_DEFAULTS,
|
||||
type BucketProtection,
|
||||
} from './StorageProtection.constants'
|
||||
|
||||
interface BucketDataProtectionFieldsProps {
|
||||
bucketName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* "Object versioning" section for the create/edit bucket modals.
|
||||
*
|
||||
* Prototype: manages local state and isn't persisted through the bucket
|
||||
* mutation yet.
|
||||
*/
|
||||
export const BucketDataProtectionFields = ({ bucketName }: BucketDataProtectionFieldsProps) => {
|
||||
const initial: BucketProtection = getMockBucketProtection(bucketName)
|
||||
|
||||
const [isVersioningEnabled, setIsVersioningEnabled] = useState(initial.versioning === 'enabled')
|
||||
const [expiryDays, setExpiryDays] = useState<number>(
|
||||
initial.versionExpiryDays ?? PROJECT_VERSIONING_DEFAULTS.versionExpiryDays
|
||||
)
|
||||
const [maxVersions, setMaxVersions] = useState<number>(
|
||||
initial.maxNoncurrentVersions ?? PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<DialogSection className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-x-6">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="enable-versioning">Object versioning</Label>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Keep previous versions of objects when they are overwritten or deleted
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-versioning"
|
||||
size="large"
|
||||
checked={isVersioningEnabled}
|
||||
onCheckedChange={setIsVersioningEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isVersioningEnabled && (
|
||||
<div className="flex flex-col gap-y-3 border-l border-border pl-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Label htmlFor="version-expiry-days">Noncurrent version retention (days)</Label>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Automatically expire noncurrent versions after this many days
|
||||
</p>
|
||||
<Input
|
||||
id="version-expiry-days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={expiryDays}
|
||||
onChange={(e) => setExpiryDays(Number(e.target.value))}
|
||||
className="max-w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Label htmlFor="max-noncurrent-versions">Max noncurrent versions</Label>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Keep at most this many noncurrent versions per object
|
||||
</p>
|
||||
<Input
|
||||
id="max-noncurrent-versions"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxVersions}
|
||||
onChange={(e) => setMaxVersions(Number(e.target.value))}
|
||||
className="max-w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -28,8 +28,6 @@ import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import z from 'zod'
|
||||
|
||||
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
|
||||
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
|
||||
import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
|
||||
@@ -37,6 +35,11 @@ import { useBucketCreateMutation } from '@/data/storage/bucket-create-mutation'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
import { BucketDataProtectionFields } from './BucketDataProtectionFields'
|
||||
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
|
||||
import { useIsStorageProtectionEnabled } from './StorageProtection.constants'
|
||||
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
@@ -86,6 +89,7 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps
|
||||
const { ref } = useParams()
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
|
||||
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
|
||||
const showProtection = useIsStorageProtectionEnabled()
|
||||
|
||||
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
|
||||
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
|
||||
@@ -395,6 +399,8 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps
|
||||
/>
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
{showProtection && <BucketDataProtectionFields />}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { BucketDataProtectionFields } from '@/components/interfaces/Storage/BucketDataProtectionFields'
|
||||
import { useIsStorageProtectionEnabled } from '@/components/interfaces/Storage/StorageProtection.constants'
|
||||
import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants'
|
||||
import {
|
||||
convertFromBytes,
|
||||
@@ -127,6 +129,7 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
|
||||
Boolean(bucket?.allowed_mime_types?.length)
|
||||
)
|
||||
|
||||
const showProtection = useIsStorageProtectionEnabled()
|
||||
const isChangingBucketVisibility = bucket?.public !== isPublicBucket
|
||||
const isMakingBucketPrivate = bucket?.public && !isPublicBucket
|
||||
const isMakingBucketPublic = !bucket?.public && isPublicBucket
|
||||
@@ -424,6 +427,8 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
|
||||
/>
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
{showProtection && <BucketDataProtectionFields bucketName={bucket?.name} />}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { AlertCircle, ChevronDown, Copy, Download, LoaderCircle, Trash2, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import {
|
||||
Button,
|
||||
@@ -7,19 +9,28 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { URL_EXPIRY_DURATION } from '../Storage.constants'
|
||||
import { StorageItem } from '../Storage.types'
|
||||
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
|
||||
import { useCopyUrl } from './useCopyUrl'
|
||||
import { useFetchFileUrlQuery } from './useFetchFileUrlQuery'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { useObjectVersionsQuery } from '@/data/storage/protection/object-versions-query'
|
||||
import type { ObjectVersion } from '@/data/storage/protection/protection-mocks'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
import { URL_EXPIRY_DURATION } from '../Storage.constants'
|
||||
import { StorageItem } from '../Storage.types'
|
||||
import { useIsStorageProtectionEnabled } from '../StorageProtection.constants'
|
||||
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
|
||||
import { useCopyUrl } from './useCopyUrl'
|
||||
import { useFetchFileUrlQuery } from './useFetchFileUrlQuery'
|
||||
import { VersionHistory } from './VersionHistory'
|
||||
|
||||
const PREVIEW_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
const PreviewFile = ({ item }: { item: StorageItem }) => {
|
||||
@@ -33,7 +44,6 @@ const PreviewFile = ({ item }: { item: StorageItem }) => {
|
||||
bucket: selectedBucket,
|
||||
})
|
||||
|
||||
// if the size is not available, we set it to be greater than the max size
|
||||
const size = +(item.metadata?.size ?? PREVIEW_SIZE_LIMIT + 1)
|
||||
const mimeType = item.metadata?.mimetype
|
||||
|
||||
@@ -116,6 +126,7 @@ const PreviewFile = ({ item }: { item: StorageItem }) => {
|
||||
|
||||
export const PreviewPane = () => {
|
||||
const {
|
||||
projectRef,
|
||||
selectedBucket,
|
||||
selectedFilePreview: file,
|
||||
setSelectedItemsToDelete,
|
||||
@@ -127,6 +138,16 @@ export const PreviewPane = () => {
|
||||
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
const showVersions = useIsStorageProtectionEnabled()
|
||||
const { data: versionsData } = useObjectVersionsQuery({
|
||||
projectRef,
|
||||
bucketId: selectedBucket?.id,
|
||||
objectName: file?.name,
|
||||
})
|
||||
const versionCount = versionsData?.length
|
||||
|
||||
const [previewedVersion, setPreviewedVersion] = useState<ObjectVersion>()
|
||||
|
||||
if (!file) return null
|
||||
|
||||
const width = 450
|
||||
@@ -135,133 +156,178 @@ export const PreviewPane = () => {
|
||||
const createdAt = file.created_at ? new Date(file.created_at).toLocaleString() : 'Unknown'
|
||||
const updatedAt = file.updated_at ? new Date(file.updated_at).toLocaleString() : 'Unknown'
|
||||
|
||||
const isPreviewingOlderVersion = previewedVersion !== undefined && !previewedVersion.isCurrent
|
||||
|
||||
const previewThumbnail = (
|
||||
<div className="relative my-4 border border-overlay">
|
||||
<div className="flex h-56 w-full items-center 2xl:h-72">
|
||||
<PreviewFile item={file} />
|
||||
</div>
|
||||
{isPreviewingOlderVersion && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-x-2 border-t border-overlay bg-surface-100/95 px-3 py-1.5">
|
||||
<p className="text-xs text-foreground-light">
|
||||
Previewing version{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{previewedVersion.versionId.slice(0, 6)}…
|
||||
</span>{' '}
|
||||
· {dayjs(previewedVersion.createdAt).format('MMM D, HH:mm')}
|
||||
</p>
|
||||
<button
|
||||
className="text-xs text-brand hover:underline"
|
||||
onClick={() => setPreviewedVersion(undefined)}
|
||||
>
|
||||
Back to latest
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const detailsContent = (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h5 className="wrap-break-word text-base text-foreground">{file.name}</h5>
|
||||
{file.isCorrupted && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle size={14} className="text-foreground-light" />
|
||||
<p className="text-sm text-foreground-light">
|
||||
File is corrupted, please delete and reupload this file again
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mimeType && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
{mimeType}
|
||||
{size && <span> - {size}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 text-xs text-foreground-lighter">Added on</label>
|
||||
<p className="text-sm text-foreground-light">{createdAt}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 text-xs text-foreground-lighter">Last modified</label>
|
||||
<p className="text-sm text-foreground-light">{updatedAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 border-b border-overlay pb-4">
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<Download />}
|
||||
disabled={file.isCorrupted}
|
||||
onClick={() => downloadFile(file)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
{selectedBucket.public ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<Copy />}
|
||||
onClick={() => onCopyUrl(file.path!)}
|
||||
disabled={file.isCorrupted}
|
||||
>
|
||||
Get URL
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<Copy />}
|
||||
iconRight={<ChevronDown />}
|
||||
disabled={file.isCorrupted}
|
||||
>
|
||||
Get URL
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="center">
|
||||
<DropdownMenuItem
|
||||
key="expires-one-week"
|
||||
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.WEEK)}
|
||||
>
|
||||
Expire in 1 week
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="expires-one-month"
|
||||
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.MONTH)}
|
||||
>
|
||||
Expire in 1 month
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="expires-one-year"
|
||||
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.YEAR)}
|
||||
>
|
||||
Expire in 1 year
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="custom-expiry"
|
||||
onClick={() => setSelectedFileCustomExpiry(file)}
|
||||
>
|
||||
Custom expiry
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
variant="outline"
|
||||
disabled={!canUpdateFiles}
|
||||
size="tiny"
|
||||
icon={<Trash2 />}
|
||||
onClick={() => setSelectedItemsToDelete([file])}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateFiles
|
||||
? 'You need additional permissions to delete this file'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete file
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id ?? file.name}
|
||||
className="h-full border-l border-overlay bg-surface-100 p-4 overflow-y-auto"
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Preview Header */}
|
||||
<div className="flex w-full justify-end text-foreground-lighter transition-colors hover:text-foreground">
|
||||
<X className="cursor-pointer" size={14} onClick={() => setSelectedFilePreview(undefined)} />
|
||||
</div>
|
||||
|
||||
{/* Preview Thumbnail*/}
|
||||
<div className="my-4 border border-overlay">
|
||||
<div className="flex h-56 w-full items-center 2xl:h-72">
|
||||
<PreviewFile item={file} />
|
||||
</div>
|
||||
</div>
|
||||
{previewThumbnail}
|
||||
|
||||
<div className="w-full space-y-6">
|
||||
{/* Preview Information */}
|
||||
<div className="space-y-1">
|
||||
<h5 className="wrap-break-word text-base text-foreground">{file.name}</h5>
|
||||
{file.isCorrupted && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle size={14} className="text-foreground-light" />
|
||||
<p className="text-sm text-foreground-light">
|
||||
File is corrupted, please delete and reupload this file again
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mimeType && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
{mimeType}
|
||||
{size && <span> - {size}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Metadata */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 text-xs text-foreground-lighter">Added on</label>
|
||||
<p className="text-sm text-foreground-light">{createdAt}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 text-xs text-foreground-lighter">Last modified</label>
|
||||
<p className="text-sm text-foreground-light">{updatedAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-2 border-b border-overlay pb-4">
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<Download />}
|
||||
disabled={file.isCorrupted}
|
||||
onClick={() => downloadFile(file)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
{selectedBucket.public ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<Copy />}
|
||||
onClick={() => onCopyUrl(file.path!)}
|
||||
disabled={file.isCorrupted}
|
||||
>
|
||||
Get URL
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<Copy />}
|
||||
iconRight={<ChevronDown />}
|
||||
disabled={file.isCorrupted}
|
||||
>
|
||||
Get URL
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="center">
|
||||
<DropdownMenuItem
|
||||
key="expires-one-week"
|
||||
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.WEEK)}
|
||||
>
|
||||
Expire in 1 week
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="expires-one-month"
|
||||
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.MONTH)}
|
||||
>
|
||||
Expire in 1 month
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="expires-one-year"
|
||||
onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.YEAR)}
|
||||
>
|
||||
Expire in 1 year
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="custom-expiry"
|
||||
onClick={() => setSelectedFileCustomExpiry(file)}
|
||||
>
|
||||
Custom expiry
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
variant="outline"
|
||||
disabled={!canUpdateFiles}
|
||||
size="tiny"
|
||||
icon={<Trash2 />}
|
||||
onClick={() => setSelectedItemsToDelete([file])}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateFiles
|
||||
? 'You need additional permissions to delete this file'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete file
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
{showVersions ? (
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList className="gap-x-4">
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="versions">
|
||||
Versions{versionCount !== undefined ? ` (${versionCount})` : ''}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="details">{detailsContent}</TabsContent>
|
||||
<TabsContent value="versions" className="pt-2">
|
||||
<VersionHistory
|
||||
projectRef={projectRef}
|
||||
bucketId={selectedBucket?.id}
|
||||
objectName={file.name}
|
||||
mimeType={file.metadata?.mimetype}
|
||||
previewedVersionId={previewedVersion?.versionId}
|
||||
onPreview={setPreviewedVersion}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
detailsContent
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Download, RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge, cn } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import {
|
||||
useObjectVersionDeleteMutation,
|
||||
useObjectVersionRestoreMutation,
|
||||
useObjectVersionsQuery,
|
||||
} from '@/data/storage/protection/object-versions-query'
|
||||
import { type ObjectVersion } from '@/data/storage/protection/protection-mocks'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
|
||||
interface VersionHistoryProps {
|
||||
projectRef?: string
|
||||
bucketId?: string
|
||||
objectName: string
|
||||
mimeType?: string
|
||||
previewedVersionId?: string
|
||||
onPreview?: (version: ObjectVersion) => void
|
||||
}
|
||||
|
||||
const shortVersion = (versionId: string) => `${versionId.slice(0, 6)}…${versionId.slice(-2)}`
|
||||
|
||||
export const VersionHistory = ({
|
||||
projectRef,
|
||||
bucketId,
|
||||
objectName,
|
||||
mimeType,
|
||||
previewedVersionId,
|
||||
onPreview,
|
||||
}: VersionHistoryProps) => {
|
||||
const {
|
||||
data: versions,
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
isSuccess,
|
||||
} = useObjectVersionsQuery({ projectRef, bucketId, objectName })
|
||||
|
||||
const [versionToDelete, setVersionToDelete] = useState<ObjectVersion>()
|
||||
|
||||
const { mutate: restoreVersion, isPending: isRestoring } = useObjectVersionRestoreMutation({
|
||||
onSuccess: () => toast.success('Version restored as the current version'),
|
||||
})
|
||||
|
||||
const { mutate: deleteVersion, isPending: isDeleting } = useObjectVersionDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Version permanently deleted')
|
||||
setVersionToDelete(undefined)
|
||||
},
|
||||
})
|
||||
|
||||
const handleRestore = (version: ObjectVersion) => {
|
||||
if (!projectRef || !bucketId) return
|
||||
restoreVersion({ projectRef, bucketId, objectName, versionId: version.versionId })
|
||||
}
|
||||
|
||||
if (isPending) return <GenericSkeletonLoader />
|
||||
if (isError) return <AlertError error={error} subject="Failed to retrieve versions" />
|
||||
|
||||
const totalSize = versions.reduce((sum, version) => sum + version.size, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h5 className="wrap-break-word text-base text-foreground">{objectName}</h5>
|
||||
<p className="text-sm text-foreground-light">
|
||||
{[mimeType, `${versions.length} versions`, `${formatBytes(totalSize)} total`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isSuccess && (
|
||||
<ol className="relative flex flex-col">
|
||||
{versions.map((version, index) => {
|
||||
const isLast = index === versions.length - 1
|
||||
return (
|
||||
<li key={version.versionId} className="flex gap-x-3">
|
||||
<div className="flex flex-col items-center pt-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'h-2.5 w-2.5 rounded-full',
|
||||
version.isCurrent ? 'bg-brand' : 'bg-foreground-muted'
|
||||
)}
|
||||
/>
|
||||
{!isLast && <span className="w-px flex-1 bg-border" />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex-1 pb-8 -mx-2 px-2 rounded-md transition-colors cursor-pointer',
|
||||
onPreview && 'hover:bg-surface-200',
|
||||
previewedVersionId === version.versionId && 'bg-surface-200'
|
||||
)}
|
||||
onClick={() => onPreview?.(version)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onPreview?.(version)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{dayjs(version.createdAt).format('MMM D, HH:mm')}
|
||||
</span>
|
||||
{version.isCurrent && <Badge variant="success">Latest</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-1" onClick={(e) => e.stopPropagation()}>
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="px-1.5"
|
||||
icon={<Download size={14} />}
|
||||
aria-label={`Download version ${shortVersion(version.versionId)}`}
|
||||
onClick={() =>
|
||||
toast.success(`Downloading ${shortVersion(version.versionId)}`)
|
||||
}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Download' } }}
|
||||
/>
|
||||
|
||||
{!version.isCurrent && (
|
||||
<>
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="px-1.5"
|
||||
icon={<RotateCcw size={14} />}
|
||||
loading={isRestoring}
|
||||
aria-label={`Restore version ${shortVersion(version.versionId)}`}
|
||||
onClick={() => handleRestore(version)}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Restore as current' } }}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="px-1.5 hover:text-destructive"
|
||||
icon={<Trash2 size={14} />}
|
||||
disabled={version.heldBySnapshot !== null}
|
||||
aria-label={`Delete version ${shortVersion(version.versionId)}`}
|
||||
onClick={() => setVersionToDelete(version)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text:
|
||||
version.heldBySnapshot !== null
|
||||
? 'Held by a snapshot — delete the snapshot first'
|
||||
: 'Delete permanently',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-foreground-lighter">
|
||||
{formatBytes(version.size)} · {version.action}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs text-foreground-muted">
|
||||
v: {shortVersion(version.versionId)}
|
||||
{version.heldBySnapshot && ` · held by ${version.heldBySnapshot}`}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
title="Restoring is non-destructive"
|
||||
description="Restoring makes an older version the new current version. The previous current version becomes a noncurrent version you can still recover."
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={versionToDelete !== undefined}
|
||||
title="Permanently delete version"
|
||||
confirmLabel="Delete permanently"
|
||||
confirmLabelLoading="Deleting..."
|
||||
loading={isDeleting}
|
||||
onCancel={() => setVersionToDelete(undefined)}
|
||||
onConfirm={() => {
|
||||
if (!projectRef || !bucketId || !versionToDelete) return
|
||||
deleteVersion({
|
||||
projectRef,
|
||||
bucketId,
|
||||
objectName,
|
||||
versionId: versionToDelete.versionId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Version{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{versionToDelete ? shortVersion(versionToDelete.versionId) : ''}
|
||||
</span>{' '}
|
||||
of {objectName} will be permanently deleted. The current version is not affected. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Shared constants + gating for the Storage Object Versioning prototype.
|
||||
*/
|
||||
|
||||
// Prototype flag: force-enabled so the surfaces are visible via `pnpm dev:studio`.
|
||||
// A real rollout would replace this with `useFlag('storageObjectVersioning')`.
|
||||
export const STORAGE_PROTECTION_ENABLED = true
|
||||
|
||||
export const useIsStorageProtectionEnabled = () => STORAGE_PROTECTION_ENABLED
|
||||
|
||||
export type BucketVersioningState = 'enabled' | 'suspended' | 'disabled'
|
||||
|
||||
export const PROJECT_VERSIONING_DEFAULTS = {
|
||||
versionExpiryDays: 30,
|
||||
maxNoncurrentVersions: 100,
|
||||
} as const
|
||||
|
||||
export interface BucketProtection {
|
||||
versioning: BucketVersioningState
|
||||
versionExpiryDays: number | null
|
||||
maxNoncurrentVersions: number | null
|
||||
}
|
||||
|
||||
const PROTECTED_BUCKETS: Record<string, BucketProtection> = {
|
||||
'match-media': {
|
||||
versioning: 'enabled',
|
||||
versionExpiryDays: PROJECT_VERSIONING_DEFAULTS.versionExpiryDays,
|
||||
maxNoncurrentVersions: PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions,
|
||||
},
|
||||
avatars: {
|
||||
versioning: 'enabled',
|
||||
versionExpiryDays: 30,
|
||||
maxNoncurrentVersions: 10,
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_PROTECTION: BucketProtection = {
|
||||
versioning: 'disabled',
|
||||
versionExpiryDays: null,
|
||||
maxNoncurrentVersions: null,
|
||||
}
|
||||
|
||||
export const getMockBucketProtection = (bucketName: string | undefined): BucketProtection =>
|
||||
(bucketName && PROTECTED_BUCKETS[bucketName]) || DEFAULT_PROTECTION
|
||||
|
||||
export const isBucketVersioned = (bucketName: string | undefined) =>
|
||||
getMockBucketProtection(bucketName).versioning === 'enabled'
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useParams } from 'common'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Card } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
||||
import {
|
||||
useBucketTrashDeleteMutation,
|
||||
useBucketTrashQuery,
|
||||
useBucketTrashRestoreMutation,
|
||||
} from '@/data/storage/protection/bucket-trash-query'
|
||||
import { type TrashObject } from '@/data/storage/protection/protection-mocks'
|
||||
|
||||
import { toggleSelectAll, toggleSelection } from './Trash.utils'
|
||||
import { TrashList } from './TrashList'
|
||||
import { TrashSelectionBar } from './TrashSelectionBar'
|
||||
|
||||
interface TrashProps {
|
||||
bucketId: string
|
||||
}
|
||||
|
||||
export const Trash = ({ bucketId }: TrashProps) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [lastToggledId, setLastToggledId] = useState<string | null>(null)
|
||||
const [objectToDelete, setObjectToDelete] = useState<TrashObject>()
|
||||
const [showBulkDelete, setShowBulkDelete] = useState(false)
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false)
|
||||
|
||||
const {
|
||||
data: objects,
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
isSuccess,
|
||||
} = useBucketTrashQuery({ projectRef: ref, bucketId })
|
||||
|
||||
const { mutate: restoreObjects, isPending: isRestoring } = useBucketTrashRestoreMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
const count = variables.objectIds.length
|
||||
toast.success(count === 1 ? 'File restored' : `${count} files restored`)
|
||||
setSelectedIds([])
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: deleteObjects, isPending: isDeleting } = useBucketTrashDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Files permanently deleted')
|
||||
setSelectedIds([])
|
||||
setObjectToDelete(undefined)
|
||||
setShowBulkDelete(false)
|
||||
setShowDeleteAll(false)
|
||||
},
|
||||
})
|
||||
|
||||
const orderedIds = (objects ?? []).map((object) => object.id)
|
||||
const selectedObjects = (objects ?? []).filter((object) => selectedIds.includes(object.id))
|
||||
const heldCount = selectedObjects.filter((object) => object.heldBySnapshot).length
|
||||
const deletableIds = selectedObjects
|
||||
.filter((object) => !object.heldBySnapshot)
|
||||
.map((object) => object.id)
|
||||
const heldInBucketCount = (objects ?? []).filter((object) => object.heldBySnapshot).length
|
||||
|
||||
const handleToggleSelect = (id: string, isShiftHeld: boolean) => {
|
||||
setSelectedIds(toggleSelection({ selectedIds, orderedIds, id, lastToggledId, isShiftHeld }))
|
||||
setLastToggledId(id)
|
||||
}
|
||||
|
||||
const handleRestore = (objectIds: string[]) => {
|
||||
if (!ref || objectIds.length === 0) return
|
||||
restoreObjects({ projectRef: ref, bucketId, objectIds })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageContainer>
|
||||
<PageSection>
|
||||
<PageSectionContent className="flex flex-col gap-y-4">
|
||||
<div className="flex items-center justify-between gap-x-3">
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Soft-deleted objects in this bucket, restorable until their retention policy expires
|
||||
them
|
||||
</p>
|
||||
{isSuccess && objects.length > 0 && (
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
icon={<Trash2 />}
|
||||
disabled={objects.length === heldInBucketCount}
|
||||
onClick={() => setShowDeleteAll(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text:
|
||||
objects.length === heldInBucketCount
|
||||
? 'Every deleted file is held by a snapshot'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete all permanently
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPending && <GenericSkeletonLoader />}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve deleted files" />}
|
||||
{isSuccess && objects.length === 0 && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="No deleted files"
|
||||
description="Deleted objects appear here and can be restored until a lifecycle policy removes them."
|
||||
/>
|
||||
)}
|
||||
{isSuccess && objects.length > 0 && (
|
||||
<>
|
||||
<Card className="overflow-hidden">
|
||||
{selectedIds.length > 0 && (
|
||||
<TrashSelectionBar
|
||||
count={selectedIds.length}
|
||||
heldCount={heldCount}
|
||||
isRestoring={isRestoring}
|
||||
onRestore={() => handleRestore(selectedIds)}
|
||||
onDelete={() => setShowBulkDelete(true)}
|
||||
onClear={() => setSelectedIds([])}
|
||||
/>
|
||||
)}
|
||||
<TrashList
|
||||
objects={objects}
|
||||
selectedIds={selectedIds}
|
||||
isRestoring={isRestoring}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onToggleSelectAll={() =>
|
||||
setSelectedIds(toggleSelectAll(selectedIds, orderedIds))
|
||||
}
|
||||
onRestore={(object) => handleRestore([object.id])}
|
||||
onDeleteForever={setObjectToDelete}
|
||||
/>
|
||||
</Card>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Items held by a snapshot stay recoverable — and billable — until every snapshot
|
||||
referencing them is deleted, even past their retention period.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={objectToDelete !== undefined}
|
||||
title="Permanently delete file"
|
||||
confirmLabel="Delete permanently"
|
||||
confirmLabelLoading="Deleting..."
|
||||
loading={isDeleting}
|
||||
onCancel={() => setObjectToDelete(undefined)}
|
||||
onConfirm={() => {
|
||||
if (!ref || !objectToDelete) return
|
||||
deleteObjects({
|
||||
projectRef: ref,
|
||||
bucketId,
|
||||
objectIds: [objectToDelete.id],
|
||||
})
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
{objectToDelete?.name} will be permanently deleted and can no longer be restored. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={showBulkDelete}
|
||||
title={`Permanently delete ${deletableIds.length} file${deletableIds.length === 1 ? '' : 's'}`}
|
||||
confirmLabel="Delete permanently"
|
||||
confirmLabelLoading="Deleting..."
|
||||
loading={isDeleting}
|
||||
onCancel={() => setShowBulkDelete(false)}
|
||||
onConfirm={() => {
|
||||
if (!ref) return
|
||||
deleteObjects({ projectRef: ref, bucketId, objectIds: deletableIds })
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
These files will be permanently deleted and can no longer be restored. This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
{heldCount > 0 && (
|
||||
<Admonition
|
||||
className="mt-3"
|
||||
type="warning"
|
||||
showIcon={false}
|
||||
title={`${heldCount} selected file${heldCount === 1 ? '' : 's'} will be kept`}
|
||||
description="Files held by a snapshot can't be deleted until every snapshot referencing them is deleted."
|
||||
/>
|
||||
)}
|
||||
</ConfirmationModal>
|
||||
|
||||
<TextConfirmModal
|
||||
variant="destructive"
|
||||
visible={showDeleteAll}
|
||||
size="medium"
|
||||
title={`Delete all deleted files in "${bucketId}"`}
|
||||
confirmLabel="Delete all permanently"
|
||||
confirmPlaceholder="Type bucket name"
|
||||
confirmString={bucketId}
|
||||
loading={isDeleting}
|
||||
onCancel={() => setShowDeleteAll(false)}
|
||||
onConfirm={() => {
|
||||
if (!ref) return
|
||||
deleteObjects({ projectRef: ref, bucketId })
|
||||
}}
|
||||
alert={{
|
||||
title: 'You cannot recover these files once deleted',
|
||||
description: 'This action cannot be undone',
|
||||
}}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Every soft-deleted file in <span className="font-bold text-foreground">{bucketId}</span>{' '}
|
||||
will be permanently deleted.
|
||||
{heldInBucketCount > 0 &&
|
||||
` ${heldInBucketCount} file${heldInBucketCount === 1 ? '' : 's'} held by a snapshot will be kept.`}
|
||||
</p>
|
||||
</TextConfirmModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toggleSelectAll, toggleSelection } from './Trash.utils'
|
||||
|
||||
const orderedIds = ['a', 'b', 'c', 'd']
|
||||
|
||||
describe('toggleSelection', () => {
|
||||
it('adds an unselected row', () => {
|
||||
expect(
|
||||
toggleSelection({
|
||||
selectedIds: [],
|
||||
orderedIds,
|
||||
id: 'b',
|
||||
lastToggledId: null,
|
||||
isShiftHeld: false,
|
||||
})
|
||||
).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('removes an already-selected row', () => {
|
||||
expect(
|
||||
toggleSelection({
|
||||
selectedIds: ['a', 'b'],
|
||||
orderedIds,
|
||||
id: 'b',
|
||||
lastToggledId: null,
|
||||
isShiftHeld: false,
|
||||
})
|
||||
).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('selects the range forwards on shift-click', () => {
|
||||
expect(
|
||||
toggleSelection({
|
||||
selectedIds: ['a'],
|
||||
orderedIds,
|
||||
id: 'c',
|
||||
lastToggledId: 'a',
|
||||
isShiftHeld: true,
|
||||
})
|
||||
).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('selects the range backwards on shift-click', () => {
|
||||
expect(
|
||||
toggleSelection({
|
||||
selectedIds: ['d'],
|
||||
orderedIds,
|
||||
id: 'b',
|
||||
lastToggledId: 'd',
|
||||
isShiftHeld: true,
|
||||
}).sort()
|
||||
).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('does not duplicate rows already in the range', () => {
|
||||
const result = toggleSelection({
|
||||
selectedIds: ['a', 'b'],
|
||||
orderedIds,
|
||||
id: 'c',
|
||||
lastToggledId: 'a',
|
||||
isShiftHeld: true,
|
||||
})
|
||||
expect(result).toEqual([...new Set(result)])
|
||||
})
|
||||
|
||||
it('falls back to a plain toggle when there is no anchor', () => {
|
||||
expect(
|
||||
toggleSelection({
|
||||
selectedIds: [],
|
||||
orderedIds,
|
||||
id: 'c',
|
||||
lastToggledId: null,
|
||||
isShiftHeld: true,
|
||||
})
|
||||
).toEqual(['c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleSelectAll', () => {
|
||||
it('selects everything when nothing is selected', () => {
|
||||
expect(toggleSelectAll([], orderedIds)).toEqual(orderedIds)
|
||||
})
|
||||
|
||||
it('selects everything when only some rows are selected', () => {
|
||||
expect(toggleSelectAll(['b'], orderedIds)).toEqual(orderedIds)
|
||||
})
|
||||
|
||||
it('clears the selection when everything is already selected', () => {
|
||||
expect(toggleSelectAll([...orderedIds], orderedIds)).toEqual([])
|
||||
})
|
||||
|
||||
it('stays empty for an empty list', () => {
|
||||
expect(toggleSelectAll([], [])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Selection helpers for the Deleted files list.
|
||||
*
|
||||
* Mirrors the file explorer's behaviour: plain click toggles one row, shift-click
|
||||
* selects the contiguous range from the last-touched row.
|
||||
*/
|
||||
|
||||
export interface ToggleSelectionParams {
|
||||
selectedIds: string[]
|
||||
orderedIds: string[]
|
||||
id: string
|
||||
lastToggledId: string | null
|
||||
isShiftHeld: boolean
|
||||
}
|
||||
|
||||
export const toggleSelection = ({
|
||||
selectedIds,
|
||||
orderedIds,
|
||||
id,
|
||||
lastToggledId,
|
||||
isShiftHeld,
|
||||
}: ToggleSelectionParams): string[] => {
|
||||
const anchorIndex = lastToggledId === null ? -1 : orderedIds.indexOf(lastToggledId)
|
||||
const targetIndex = orderedIds.indexOf(id)
|
||||
|
||||
if (isShiftHeld && anchorIndex !== -1 && targetIndex !== -1) {
|
||||
const [start, end] =
|
||||
anchorIndex < targetIndex ? [anchorIndex, targetIndex] : [targetIndex, anchorIndex]
|
||||
const range = orderedIds.slice(start, end + 1)
|
||||
return [...new Set([...selectedIds, ...range])]
|
||||
}
|
||||
|
||||
return selectedIds.includes(id)
|
||||
? selectedIds.filter((selectedId) => selectedId !== id)
|
||||
: [...selectedIds, id]
|
||||
}
|
||||
|
||||
export const toggleSelectAll = (selectedIds: string[], orderedIds: string[]): string[] =>
|
||||
orderedIds.length > 0 && orderedIds.every((id) => selectedIds.includes(id)) ? [] : orderedIds
|
||||
@@ -0,0 +1,138 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Lock } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { type TrashObject } from '@/data/storage/protection/protection-mocks'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
|
||||
interface TrashListProps {
|
||||
objects: TrashObject[]
|
||||
selectedIds: string[]
|
||||
isRestoring: boolean
|
||||
onToggleSelect: (id: string, isShiftHeld: boolean) => void
|
||||
onToggleSelectAll: () => void
|
||||
onRestore: (object: TrashObject) => void
|
||||
onDeleteForever: (object: TrashObject) => void
|
||||
}
|
||||
|
||||
export const TrashList = ({
|
||||
objects,
|
||||
selectedIds,
|
||||
isRestoring,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onRestore,
|
||||
onDeleteForever,
|
||||
}: TrashListProps) => {
|
||||
const isAllSelected = objects.length > 0 && objects.every((o) => selectedIds.includes(o.id))
|
||||
const isSomeSelected = selectedIds.length > 0 && !isAllSelected
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
className={cn(isSomeSelected && 'opacity-60')}
|
||||
onClick={onToggleSelectAll}
|
||||
aria-label="Select all deleted files"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Object</TableHead>
|
||||
<TableHead>Original location</TableHead>
|
||||
<TableHead>Deleted</TableHead>
|
||||
<TableHead className="text-right">Size</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{objects.map((object) => {
|
||||
const isSelected = selectedIds.includes(object.id)
|
||||
return (
|
||||
<TableRow
|
||||
key={object.id}
|
||||
className={cn('group', isSelected && 'bg-selection hover:bg-selection')}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className={cn(
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||
)}
|
||||
onClick={(event) => onToggleSelect(object.id, event.nativeEvent.shiftKey)}
|
||||
aria-label={`Select ${object.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground">{object.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-foreground-lighter">
|
||||
{object.originalPath}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{dayjs(object.deletedAt).fromNow()}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{dayjs(object.deletedAt).format('MMM D, YYYY · HH:mm')} · {object.deletedBy}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-foreground-light tabular-nums">
|
||||
{formatBytes(object.size)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{object.heldBySnapshot ? (
|
||||
<span className="flex items-center gap-x-1.5 text-destructive">
|
||||
<Lock size={12} /> Held by snapshot
|
||||
</span>
|
||||
) : object.expiresAt ? (
|
||||
<span className="text-warning-600">{dayjs(object.expiresAt).fromNow()}</span>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Button variant="default" loading={isRestoring} onClick={() => onRestore(object)}>
|
||||
Restore
|
||||
</Button>
|
||||
<ButtonTooltip
|
||||
variant="outline"
|
||||
disabled={object.heldBySnapshot}
|
||||
onClick={() => onDeleteForever(object)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: object.heldBySnapshot
|
||||
? 'Held by a snapshot — delete the snapshot first'
|
||||
: 'Delete permanently',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete permanently
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { RotateCcw, Trash2, X } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
|
||||
import { bulkActionBarClassName } from '../StorageExplorer/storageExplorerChrome'
|
||||
|
||||
interface TrashSelectionBarProps {
|
||||
count: number
|
||||
heldCount: number
|
||||
isRestoring: boolean
|
||||
onRestore: () => void
|
||||
onDelete: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export const TrashSelectionBar = ({
|
||||
count,
|
||||
heldCount,
|
||||
isRestoring,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onClear,
|
||||
}: TrashSelectionBarProps) => {
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
const isEveryItemHeld = heldCount === count
|
||||
|
||||
return (
|
||||
<div className={bulkActionBarClassName}>
|
||||
<span className="font-mono text-xs text-foreground-light">
|
||||
<span className="tabular-nums">{count}</span> item{count !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
|
||||
{heldCount > 0 && (
|
||||
<span className="text-xs text-foreground-lighter">{heldCount} held by a snapshot</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<RotateCcw size={12} />}
|
||||
loading={isRestoring}
|
||||
disabled={!canUpdateFiles}
|
||||
onClick={onRestore}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateFiles
|
||||
? 'You need additional permissions to restore files'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</ButtonTooltip>
|
||||
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<Trash2 size={12} />}
|
||||
disabled={!canUpdateFiles || isEveryItemHeld}
|
||||
onClick={onDelete}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateFiles
|
||||
? 'You need additional permissions to delete files'
|
||||
: isEveryItemHeld
|
||||
? 'Every selected file is held by a snapshot'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete permanently
|
||||
</ButtonTooltip>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
icon={<X size={12} />}
|
||||
title="Clear selection"
|
||||
className="px-1.5 text-foreground-lighter hover:text-foreground"
|
||||
onClick={onClear}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,13 @@ export const storageKeys = {
|
||||
...(path ? [path] : []),
|
||||
...(params ? [params] : []),
|
||||
] as const,
|
||||
objectVersions: (
|
||||
projectRef: string | undefined,
|
||||
bucketId: string | undefined,
|
||||
objectName: string | undefined
|
||||
) => ['projects', projectRef, 'buckets', bucketId, 'object-versions', objectName] as const,
|
||||
trash: (projectRef: string | undefined, bucketId: string | undefined) =>
|
||||
['projects', projectRef, 'buckets', bucketId, 'trash'] as const,
|
||||
icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) =>
|
||||
[projectRef, 'warehouse', warehouse, 'namespaces'] as const,
|
||||
icebergNamespace: ({
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMutation, UseMutationOptions, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { storageKeys } from '../keys'
|
||||
import { getMockTrashObjects, mockDelay, type TrashObject } from './protection-mocks'
|
||||
|
||||
export type BucketTrashVariables = {
|
||||
projectRef?: string
|
||||
bucketId?: string
|
||||
}
|
||||
|
||||
export const useBucketTrashQuery = ({ projectRef, bucketId }: BucketTrashVariables) =>
|
||||
useQuery<TrashObject[], Error>({
|
||||
queryKey: storageKeys.trash(projectRef, bucketId),
|
||||
queryFn: () => mockDelay(getMockTrashObjects(bucketId ?? '')),
|
||||
enabled: !!projectRef && !!bucketId,
|
||||
})
|
||||
|
||||
type TrashRestoreVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
objectIds: string[]
|
||||
}
|
||||
|
||||
export const useBucketTrashRestoreMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<void, Error, TrashRestoreVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, TrashRestoreVariables>({
|
||||
mutationFn: () => mockDelay(undefined, 500),
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.trash(variables.projectRef, variables.bucketId),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to restore objects: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
type TrashDeleteVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
objectIds?: string[]
|
||||
}
|
||||
|
||||
export const useBucketTrashDeleteMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<void, Error, TrashDeleteVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, TrashDeleteVariables>({
|
||||
mutationFn: () => mockDelay(undefined, 600),
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.trash(variables.projectRef, variables.bucketId),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to delete objects: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMutation, UseMutationOptions, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { storageKeys } from '../keys'
|
||||
import { getMockObjectVersions, mockDelay, type ObjectVersion } from './protection-mocks'
|
||||
|
||||
export type ObjectVersionsVariables = {
|
||||
projectRef?: string
|
||||
bucketId?: string
|
||||
objectName?: string
|
||||
}
|
||||
|
||||
export const useObjectVersionsQuery = ({
|
||||
projectRef,
|
||||
bucketId,
|
||||
objectName,
|
||||
}: ObjectVersionsVariables) =>
|
||||
useQuery<ObjectVersion[], Error>({
|
||||
queryKey: storageKeys.objectVersions(projectRef, bucketId, objectName),
|
||||
queryFn: () => mockDelay(getMockObjectVersions(objectName ?? '')),
|
||||
enabled: !!projectRef && !!bucketId && !!objectName,
|
||||
})
|
||||
|
||||
type ObjectVersionRestoreVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
objectName: string
|
||||
versionId: string
|
||||
}
|
||||
|
||||
export const useObjectVersionRestoreMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<void, Error, ObjectVersionRestoreVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, ObjectVersionRestoreVariables>({
|
||||
mutationFn: () => mockDelay(undefined, 600),
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.objectVersions(
|
||||
variables.projectRef,
|
||||
variables.bucketId,
|
||||
variables.objectName
|
||||
),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to restore version: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
type ObjectVersionDeleteVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
objectName: string
|
||||
versionId: string
|
||||
}
|
||||
|
||||
export const useObjectVersionDeleteMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<void, Error, ObjectVersionDeleteVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, ObjectVersionDeleteVariables>({
|
||||
mutationFn: () => mockDelay(undefined, 500),
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.objectVersions(
|
||||
variables.projectRef,
|
||||
variables.bucketId,
|
||||
variables.objectName
|
||||
),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to delete version: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* PROTOTYPE mock data for Storage Object Versioning & Deleted files.
|
||||
*
|
||||
* These features don't have a platform API yet — this module returns
|
||||
* deterministic, in-memory sample data so the dashboard surfaces can be
|
||||
* designed and demoed end-to-end. The query and mutation hooks in this folder
|
||||
* are shaped exactly like the real ones so swapping in a real fetcher later is
|
||||
* a localized change.
|
||||
*/
|
||||
|
||||
export type ObjectVersionAction = 'initial upload' | 'overwrite' | 'restore'
|
||||
|
||||
export interface ObjectVersion {
|
||||
versionId: string
|
||||
size: number
|
||||
createdAt: string
|
||||
isCurrent: boolean
|
||||
action: ObjectVersionAction
|
||||
heldBySnapshot: string | null
|
||||
}
|
||||
|
||||
export interface TrashObject {
|
||||
id: string
|
||||
name: string
|
||||
originalPath: string
|
||||
deletedAt: string
|
||||
deletedBy: string
|
||||
size: number
|
||||
expiresAt: string | null
|
||||
heldBySnapshot: boolean
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024
|
||||
const KB = 1024
|
||||
|
||||
const BASE_DATE = '2026-07-24T09:14:00.000Z'
|
||||
|
||||
const daysAgo = (days: number, time = '02:00:00') => {
|
||||
const base = new Date(BASE_DATE)
|
||||
base.setUTCDate(base.getUTCDate() - days)
|
||||
const [h, m, s] = time.split(':').map(Number)
|
||||
base.setUTCHours(h, m, s, 0)
|
||||
return base.toISOString()
|
||||
}
|
||||
|
||||
const daysAhead = (days: number) => daysAgo(-days)
|
||||
|
||||
export const getMockObjectVersions = (_objectName: string): ObjectVersion[] => [
|
||||
{
|
||||
versionId: '8f3a2c9b41c1',
|
||||
size: 812 * KB,
|
||||
createdAt: BASE_DATE,
|
||||
isCurrent: true,
|
||||
action: 'overwrite',
|
||||
heldBySnapshot: null,
|
||||
},
|
||||
{
|
||||
versionId: '2b7d9153aa9e',
|
||||
size: 790 * KB,
|
||||
createdAt: daysAgo(4, '18:02:00'),
|
||||
isCurrent: false,
|
||||
action: 'overwrite',
|
||||
heldBySnapshot: 'snap_8f3a…c1',
|
||||
},
|
||||
{
|
||||
versionId: 'a19c04f7de40',
|
||||
size: 760 * KB,
|
||||
createdAt: daysAgo(10, '11:40:00'),
|
||||
isCurrent: false,
|
||||
action: 'overwrite',
|
||||
heldBySnapshot: null,
|
||||
},
|
||||
{
|
||||
versionId: '5c0278b3ac7a',
|
||||
size: 744 * KB,
|
||||
createdAt: daysAgo(22, '08:20:00'),
|
||||
isCurrent: false,
|
||||
action: 'initial upload',
|
||||
heldBySnapshot: 'snap_a19c…40',
|
||||
},
|
||||
]
|
||||
|
||||
export const getMockTrashObjects = (_bucketId: string): TrashObject[] => [
|
||||
{
|
||||
id: 'trash-1',
|
||||
name: 'round-3/final.png',
|
||||
originalPath: 'matches/round-3/',
|
||||
deletedAt: daysAgo(0, '07:20:00'),
|
||||
deletedBy: 'jane@acme.co',
|
||||
size: 1.1 * MB,
|
||||
expiresAt: daysAhead(30),
|
||||
heldBySnapshot: false,
|
||||
},
|
||||
{
|
||||
id: 'trash-2',
|
||||
name: 'roster.csv',
|
||||
originalPath: 'exports/',
|
||||
deletedAt: daysAgo(1, '14:20:00'),
|
||||
deletedBy: 'api key ····a91',
|
||||
size: 44 * KB,
|
||||
expiresAt: daysAhead(29),
|
||||
heldBySnapshot: false,
|
||||
},
|
||||
{
|
||||
id: 'trash-3',
|
||||
name: 'og/banner.png',
|
||||
originalPath: 'og/',
|
||||
deletedAt: daysAgo(3, '09:02:00'),
|
||||
deletedBy: 'jane@acme.co',
|
||||
size: 820 * KB,
|
||||
expiresAt: null,
|
||||
heldBySnapshot: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const mockDelay = <T>(value: T, ms = 350): Promise<T> =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(value), ms))
|
||||
@@ -2,7 +2,7 @@ import { useParams } from 'common'
|
||||
import { ChevronDown, FolderOpen, Settings, Shield, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { parseAsBoolean, parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
NavMenu,
|
||||
NavMenuItem,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
@@ -33,12 +35,17 @@ import { PublicBucketWarning } from '@/components/interfaces/Storage/PublicBucke
|
||||
import { PUBLIC_BUCKET_TOOLTIP } from '@/components/interfaces/Storage/Storage.constants'
|
||||
import StorageBucketsError from '@/components/interfaces/Storage/StorageBucketsError'
|
||||
import { StorageExplorer } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer'
|
||||
import { useIsStorageProtectionEnabled } from '@/components/interfaces/Storage/StorageProtection.constants'
|
||||
import { Trash } from '@/components/interfaces/Storage/Trash/Trash'
|
||||
import { useBucketPolicyCount } from '@/components/interfaces/Storage/useBucketPolicyCount'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout'
|
||||
import { StorageExplorerStateContextProvider } from '@/state/storage-explorer'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
type BucketView = 'files' | 'trash'
|
||||
const BUCKET_VIEWS: BucketView[] = ['files', 'trash']
|
||||
|
||||
const BucketPage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { bucketId, ref } = useParams()
|
||||
@@ -57,8 +64,16 @@ const BucketPage: NextPageWithLayout = () => {
|
||||
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
|
||||
)
|
||||
|
||||
const [view, setView] = useQueryState(
|
||||
'view',
|
||||
parseAsStringEnum<BucketView>(BUCKET_VIEWS)
|
||||
.withDefault('files')
|
||||
.withOptions({ history: 'push', clearOnDefault: true })
|
||||
)
|
||||
|
||||
const { getPolicyCount } = useBucketPolicyCount()
|
||||
const policyCount = bucket ? getPolicyCount(bucket.id) : 0
|
||||
const showProtection = useIsStorageProtectionEnabled()
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && !bucket) {
|
||||
@@ -162,16 +177,34 @@ const BucketPage: NextPageWithLayout = () => {
|
||||
</BreadcrumbList>
|
||||
</PageBreadcrumbs>
|
||||
|
||||
<PageContainer size="full" className="flex flex-1 min-h-0 flex-col px-0 xl:px-0">
|
||||
{ref && bucketId && (
|
||||
<div className="px-4 py-4 empty:hidden">
|
||||
<PublicBucketWarning projectRef={ref} bucketId={bucketId} />
|
||||
{showProtection && (
|
||||
<NavMenu aria-label="Bucket views" className="px-4">
|
||||
<NavMenuItem active={view === 'files'}>
|
||||
<button type="button" onClick={() => setView('files')}>
|
||||
Files
|
||||
</button>
|
||||
</NavMenuItem>
|
||||
<NavMenuItem active={view === 'trash'}>
|
||||
<button type="button" onClick={() => setView('trash')}>
|
||||
Deleted files
|
||||
</button>
|
||||
</NavMenuItem>
|
||||
</NavMenu>
|
||||
)}
|
||||
|
||||
{view === 'files' && (
|
||||
<PageContainer size="full" className="flex flex-1 min-h-0 flex-col px-0 xl:px-0">
|
||||
{ref && bucketId && (
|
||||
<div className="px-4 py-4 empty:hidden">
|
||||
<PublicBucketWarning projectRef={ref} bucketId={bucketId} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<StorageExplorer />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<StorageExplorer />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageContainer>
|
||||
)}
|
||||
{view === 'trash' && bucket && <Trash bucketId={bucket.name} />}
|
||||
</div>
|
||||
|
||||
{bucket && (
|
||||
|
||||
Reference in new issue
Block a user