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:
Claude committed 2026-08-03 16:58:25 +00:00
1 parent 9e225a279b
commit ca875d2e0d
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>
)
}
+7
View File
@@ -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 && (