studio(chore): improve storage bucket presentation (#40230)

* badge and admonition updates

* whole file row tappable

* file bucket improvements

* match tables across all storage types

* keyboard focussable rows

* share function

* other bucket types

* fix: a11y of table rows

* clean up focus state on rows

* accessibility

* address review comments

* move accessibility

---------

Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
This commit is contained in:
Danny White
2025-11-11 16:30:57 +11:00
committed by GitHub
parent ff599edf14
commit 1d54b9d72b
21 changed files with 323 additions and 306 deletions

View File

@@ -42,6 +42,7 @@ We use Tailwind for styling.
- 'text-warning' for calling out information that needs action
- 'text-destructive' for calling out when something went wrong
- When needing to apply typography styles, read @apps/studio/styles/typography.scss and use one of the available classes instead of hard coding classes e.g. use "heading-default" instead of "text-sm font-medium"
- When applying focus styles for keyboard navigation, read @apps/studio/styles/focus.scss for any appropriate classes for consistency with other focus styles
## Page structure

View File

@@ -35,6 +35,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/icons',
items: [],
},
{
items: [],
href: '/docs/ui-patterns/accessibility',
title: 'Accessibility',
},
],
},
{

View File

@@ -0,0 +1,50 @@
---
title: Accessibility
description: Make Supabase work for everyone.
---
Accessibility is about making an interface work for as many people as possible across as many circumstances as possible. All of us lean on affordances that accessible experiences provide:
- Keyboard navigation
- Legible and resizable elements
- Large tap targets
- Clear and simple language
## Checklist
About to push some code? At a minimum, check your work against this list:
- Are interactive page elements [keyboard-focusable](#focus-management)?
- Are all elements announcable by a [screen reader](#screen-reader-support)?
- Are textual elements legible and scalable?
- Can I use this on a smaller and/or older device?
## Focus management
All interactive page elements should be reachable by keyboard. They should also provide visual feedback upon selection via a `focus-visible` state. We use consistent focus styles such as `inset-focus` so users recognize this state instantly.
```jsx
<TableCell>
<p>{name}</p>
<button
className={cn('absolute inset-0', 'inset-focus')}
onClick={(event) => handleBucketNavigation(name, event)}
>
<span className="sr-only">Go to bucket details</span>
</button>
</TableCell>
```
Consider also affordances like `ctrl` and `meta` key support for opening in a new tab. Anything that you can do with a mouse input should be replicable by keyboard.
## Screen reader support
Textual elements are supported out-of-the-box by screen readers. Imagery of course should be described by `alt` tags.
Less obvious however are scaffolding elements that only makes sense visually, when paired with other content. For example: a table column for actions may not have a visual _Actions_ label because its purpose is obvious to a sighted person. For everyone elses sake, this column should be titled with `sr-only` text:
```jsx
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
```

View File

@@ -64,7 +64,7 @@ export const DeleteAnalyticsBucketModal = ({
visible={visible}
size="medium"
variant="destructive"
title={`Confirm deletion of ${bucketId}`}
title={`Delete bucket “${bucketId}`}
loading={isDeleting}
confirmPlaceholder="Type bucket name"
confirmString={bucketId ?? ''}

View File

@@ -1,37 +1,23 @@
import { ExternalLink, MoreVertical, Search, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { ChevronRight, ExternalLink, Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useParams } from 'common'
import { ScaffoldHeader, ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { AnalyticsBucket, useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import {
Button,
Card,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import { Bucket as BucketIcon } from 'icons'
import { Button, Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { Admonition, TimestampInfo } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateAnalyticsBucketModal } from './CreateAnalyticsBucketModal'
import { DeleteAnalyticsBucketModal } from './DeleteAnalyticsBucketModal'
export const AnalyticsBuckets = () => {
const { ref } = useParams()
const router = useRouter()
const [filterString, setFilterString] = useState('')
const [selectedBucket, setSelectedBucket] = useState<AnalyticsBucket>()
const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null)
const { data: buckets = [], isLoading: isLoadingBuckets } = useAnalyticsBucketsQuery({
projectRef: ref,
@@ -41,13 +27,25 @@ export const AnalyticsBuckets = () => {
filterString.length === 0 ? true : bucket.id.toLowerCase().includes(filterString.toLowerCase())
)
const handleBucketNavigation = (
bucketId: string,
event: React.MouseEvent | React.KeyboardEvent
) => {
const url = `/project/${ref}/storage/analytics/buckets/${encodeURIComponent(bucketId)}`
if (event.metaKey || event.ctrlKey) {
window.open(url, '_blank')
} else {
router.push(url)
}
}
return (
<ScaffoldSection isFullWidth>
<Admonition
type="warning"
type="note"
layout="horizontal"
className="mb-12 [&>div]:!translate-y-0 [&>svg]:!translate-y-1"
title="Analytics buckets are in alpha"
className="-mt-4 mb-8 [&>div]:!translate-y-0 [&>svg]:!translate-y-1"
title="Private alpha"
actions={
<Button asChild type="default" icon={<ExternalLink />}>
<a
@@ -55,15 +53,16 @@ export const AnalyticsBuckets = () => {
rel="noopener noreferrer"
href="https://github.com/orgs/supabase/discussions/40116"
>
Leave feedback
Share feedback
</a>
</Button>
}
>
<p className="!leading-normal !mb-0">
Expect rapid changes, limited features, and possible breaking updates as we expand access.
<p className="!leading-normal !mb-0 text-balance">
Analytics buckets are now in private alpha. Expect rapid changes, limited features, and
possible breaking updates. Please share feedback as we refine the experience and expand
access.
</p>
<p className="!leading-normal !mb-0">Please share feedback as we refine the experience!</p>
</Admonition>
{!isLoadingBuckets &&
@@ -94,9 +93,16 @@ export const AnalyticsBuckets = () => {
<Table>
<TableHeader>
<TableRow>
{analyticsBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead />
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -111,15 +117,18 @@ export const AnalyticsBuckets = () => {
</TableRow>
)}
{analyticsBuckets.map((bucket) => (
<TableRow key={bucket.id}>
<TableRow key={bucket.id} className="relative cursor-pointer h-16">
<TableCell className="w-2 pr-1">
<BucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<Link
href={`/project/${ref}/storage/analytics/buckets/${encodeURIComponent(bucket.id)}`}
title={bucket.id}
className="text-link-table-cell"
<p className="whitespace-nowrap max-w-[512px] truncate">{bucket.id}</p>
<button
className={cn('absolute inset-0', 'inset-focus')}
onClick={(event) => handleBucketNavigation(bucket.id, event)}
>
{bucket.id}
</Link>
<span className="sr-only">Go to table details</span>
</button>
</TableCell>
<TableCell>
@@ -132,31 +141,8 @@ export const AnalyticsBuckets = () => {
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button asChild type="default">
<Link
href={`/project/${ref}/storage/analytics/buckets/${encodeURIComponent(bucket.id)}`}
>
View contents
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={(e) => {
setModal('delete')
setSelectedBucket(bucket)
}}
>
<Trash2 size={12} />
<p>Delete bucket</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
@@ -167,14 +153,6 @@ export const AnalyticsBuckets = () => {
)}
</div>
)}
{selectedBucket && (
<DeleteAnalyticsBucketModal
visible={modal === 'delete'}
bucketId={selectedBucket.id}
onClose={() => setModal(null)}
/>
)}
</ScaffoldSection>
)
}

View File

@@ -229,7 +229,7 @@ export const CreateBucketModal = ({
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create a storage bucket</DialogTitle>
<DialogTitle>Create file bucket</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />

View File

@@ -83,7 +83,7 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
visible={visible}
size="medium"
variant="destructive"
title={`Confirm deletion of ${bucket.id}`}
title={`Delete bucket “${bucket.id}`}
loading={isDeletingBucket || isDeletingPolicies}
confirmPlaceholder="Type bucket name"
confirmString={bucket.id}

View File

@@ -197,7 +197,7 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Edit bucket "${bucket?.name}"`}</DialogTitle>
<DialogTitle>{`Edit bucket ${bucket?.name}`}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />

View File

@@ -55,7 +55,7 @@ export const EmptyBucketModal = ({ visible, bucket, onClose }: EmptyBucketModalP
>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Confirm to delete all contents from ${bucket?.name}`}</DialogTitle>
<DialogTitle>{`Empty bucket “${bucket?.name}`}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Admonition
@@ -65,7 +65,9 @@ export const EmptyBucketModal = ({ visible, bucket, onClose }: EmptyBucketModalP
description="The contents of your bucket cannot be recovered once deleted."
/>
<DialogSection>
<p className="text-sm">Are you sure you want to empty the bucket "{bucket?.name}"?</p>
<p className="text-sm">
Are you sure you want to remove all contents from the bucket {bucket?.name}?
</p>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={isLoading} onClick={onClose}>

View File

@@ -1,6 +1,3 @@
import { Edit, FolderOpen, MoreVertical, Trash2 } from 'lucide-react'
import Link from 'next/link'
import {
VirtualizedTableCell,
VirtualizedTableHead,
@@ -8,29 +5,21 @@ import {
VirtualizedTableRow,
} from 'components/ui/VirtualizedTable'
import { Bucket } from 'data/storage/buckets-query'
import { Bucket as BucketIcon } from 'icons'
import { formatBytes } from 'lib/helpers'
import {
Badge,
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { ChevronRight } from 'lucide-react'
import { useRouter } from 'next/navigation'
import type React from 'react'
import { Badge, cn, TableCell, TableHead, TableHeader, TableRow } from 'ui'
type BucketTableMode = 'standard' | 'virtualized'
type BucketTableHeaderProps = {
mode: BucketTableMode
hasBuckets?: boolean
}
export const BucketTableHeader = ({ mode }: BucketTableHeaderProps) => {
export const BucketTableHeader = ({ mode, hasBuckets = true }: BucketTableHeaderProps) => {
const BucketTableHeader = mode === 'standard' ? TableHeader : VirtualizedTableHeader
const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow
const BucketTableHead = mode === 'standard' ? TableHead : VirtualizedTableHead
@@ -40,7 +29,12 @@ export const BucketTableHeader = ({ mode }: BucketTableHeaderProps) => {
return (
<BucketTableHeader>
<BucketTableRow>
<BucketTableHead className={cn('w-[280px]', stickyClasses)}>Name</BucketTableHead>
{hasBuckets && (
<BucketTableHead className={`${stickyClasses} w-2 pr-1`}>
<span className="sr-only">Icon</span>
</BucketTableHead>
)}
<BucketTableHead className={stickyClasses}>Name</BucketTableHead>
<BucketTableHead className={stickyClasses}>Policies</BucketTableHead>
<BucketTableHead className={stickyClasses}>File size limit</BucketTableHead>
<BucketTableHead className={stickyClasses}>Allowed MIME types</BucketTableHead>
@@ -65,8 +59,8 @@ export const BucketTableEmptyState = ({ mode, filterString }: BucketTableEmptySt
<BucketTableRow className="[&>td]:hover:bg-inherit">
<BucketTableCell colSpan={5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
<p className="text-sm text-foreground-lighter">
Your search for {filterString} did not return any results
</p>
</BucketTableCell>
</BucketTableRow>
@@ -79,8 +73,6 @@ type BucketTableRowProps = {
projectRef: string
formattedGlobalUploadLimit: string
getPolicyCount: (bucketName: string) => number
setSelectedBucket: (bucket: Bucket) => void
setModal: (modal: 'edit' | 'empty' | 'delete' | null) => void
}
export const BucketTableRow = ({
@@ -89,25 +81,40 @@ export const BucketTableRow = ({
projectRef,
formattedGlobalUploadLimit,
getPolicyCount,
setSelectedBucket,
setModal,
}: BucketTableRowProps) => {
const BucketTableRow = mode === 'standard' ? TableRow : VirtualizedTableRow
const BucketTableCell = mode === 'standard' ? TableCell : VirtualizedTableCell
const router = useRouter()
const handleBucketNavigation = (
bucketId: string,
event: React.MouseEvent | React.KeyboardEvent
) => {
const url = `/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucketId)}`
if (event.metaKey || event.ctrlKey) {
window.open(url, '_blank')
} else {
router.push(url)
}
}
return (
<BucketTableRow key={bucket.id}>
<BucketTableCell>
<div className="flex items-center gap-2">
<Link
href={`/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucket.id)}`}
title={bucket.id}
className="text-link-table-cell"
>
{bucket.id}
</Link>
<BucketTableRow key={bucket.id} className="relative cursor-pointer h-16">
<BucketTableCell className="w-2 pr-1">
<BucketIcon size={16} className="text-foreground-muted" />
</BucketTableCell>
<BucketTableCell className="flex-1">
<div className="flex items-center gap-2.5">
<p className="whitespace-nowrap max-w-[512px] truncate">{bucket.id}</p>
{bucket.public && <Badge variant="warning">Public</Badge>}
</div>
<button
className={cn('absolute inset-0', 'inset-focus')}
onClick={(event) => handleBucketNavigation(bucket.id, event)}
>
<span className="sr-only">Go to bucket details</span>
</button>
</BucketTableCell>
<BucketTableCell>
@@ -115,7 +122,9 @@ export const BucketTableRow = ({
</BucketTableCell>
<BucketTableCell>
<p className={bucket.file_size_limit ? 'text-foreground-light' : 'text-foreground-muted'}>
<p
className={`whitespace-nowrap ${bucket.file_size_limit ? 'text-foreground-light' : 'text-foreground-muted'}`}
>
{bucket.file_size_limit
? formatBytes(bucket.file_size_limit)
: `Unset (${formattedGlobalUploadLimit})`}
@@ -131,71 +140,10 @@ export const BucketTableRow = ({
</BucketTableCell>
<BucketTableCell>
<div className="flex justify-end gap-2">
<Button asChild type="default">
<Link
href={`/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucket.id)}`}
>
View files
</Link>
</Button>
<BucketDropdownMenu
bucket={bucket}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
/>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</BucketTableCell>
</BucketTableRow>
)
}
type BucketDropdownMenuProps = {
bucket: Bucket
setSelectedBucket: (bucket: Bucket) => void
setModal: (modal: 'edit' | 'empty' | 'delete' | null) => void
}
const BucketDropdownMenu = ({ bucket, setSelectedBucket, setModal }: BucketDropdownMenuProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => {
setModal('edit')
setSelectedBucket(bucket)
}}
>
<Edit size={12} />
<p>Edit bucket</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => {
setModal('empty')
setSelectedBucket(bucket)
}}
>
<FolderOpen size={12} />
<p>Empty bucket</p>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => {
setModal('delete')
setSelectedBucket(bucket)
}}
>
<Trash2 size={12} />
<p>Delete bucket</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -8,8 +8,6 @@ type BucketsTableProps = {
projectRef: string
filterString: string
formattedGlobalUploadLimit: string
setSelectedBucket: (bucket: Bucket) => void
setModal: (modal: 'edit' | 'empty' | 'delete' | null) => void
getPolicyCount: (bucketName: string) => number
}
@@ -27,8 +25,6 @@ const BucketsTableUnvirtualized = ({
projectRef,
filterString,
formattedGlobalUploadLimit,
setSelectedBucket,
setModal,
getPolicyCount,
}: BucketsTableProps) => {
const showSearchEmptyState = buckets.length === 0 && filterString.length > 0
@@ -37,7 +33,7 @@ const BucketsTableUnvirtualized = ({
<Table
containerProps={{ containerClassName: 'h-full overflow-auto', className: 'overflow-visible' }}
>
<BucketTableHeader mode="standard" />
<BucketTableHeader mode="standard" hasBuckets={buckets.length > 0} />
<TableBody>
{showSearchEmptyState ? (
<BucketTableEmptyState mode="standard" filterString={filterString} />
@@ -50,8 +46,6 @@ const BucketsTableUnvirtualized = ({
projectRef={projectRef}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
getPolicyCount={getPolicyCount}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
/>
))
)}
@@ -65,15 +59,13 @@ const BucketsTableVirtualized = ({
projectRef,
filterString,
formattedGlobalUploadLimit,
setSelectedBucket,
setModal,
getPolicyCount,
}: BucketsTableProps) => {
const showSearchEmptyState = buckets.length === 0 && filterString.length > 0
return (
<VirtualizedTable data={buckets} estimateSize={() => 59} getItemKey={(bucket) => bucket.id}>
<BucketTableHeader mode="virtualized" />
<BucketTableHeader mode="virtualized" hasBuckets={buckets.length > 0} />
<VirtualizedTableBody<Bucket>
paddingColSpan={5}
emptyContent={
@@ -90,8 +82,6 @@ const BucketsTableVirtualized = ({
projectRef={projectRef}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
getPolicyCount={getPolicyCount}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
/>
)}
</VirtualizedTableBody>

View File

@@ -109,8 +109,6 @@ export const FilesBuckets = () => {
projectRef={ref ?? '_'}
filterString={filterString}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
setSelectedBucket={setSelectedBucket}
setModal={setModal}
getPolicyCount={getPolicyCount}
/>
</Card>

View File

@@ -34,7 +34,11 @@ export const StorageMenuV2 = () => {
<Menu.Item rounded active={isSelected}>
<div className="flex items-center justify-between">
<p className="truncate">{config.displayName}</p>
{isAlphaEnabled && <Badge variant="warning">ALPHA</Badge>}
{isAlphaEnabled && (
<Badge variant="default" size="small">
New
</Badge>
)}
</div>
</Menu.Item>
</Link>

View File

@@ -61,7 +61,7 @@ export const DeleteVectorBucketModal = ({
visible={visible}
size="medium"
variant="destructive"
title={`Confirm deletion of ${bucketName}`}
title={`Delete bucket “${bucketName}`}
loading={isDeletingBucket || isDeletingIndexes}
confirmPlaceholder="Type bucket name"
confirmString={bucketName ?? ''}

View File

@@ -1,31 +1,19 @@
import { ExternalLink, MoreVertical, Search, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { ChevronRight, ExternalLink, Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import type React from 'react'
import { useState } from 'react'
import { useParams } from 'common'
import { ScaffoldHeader, ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query'
import {
Button,
Card,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { Bucket as BucketIcon } from 'icons'
import { Button, Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateVectorBucketDialog } from './CreateVectorBucketDialog'
import { DeleteVectorBucketModal } from './DeleteVectorBucketModal'
/**
* [Joshen] Low-priority refactor: We should use a virtualized table here as per how we do it
@@ -34,12 +22,9 @@ import { DeleteVectorBucketModal } from './DeleteVectorBucketModal'
export const VectorsBuckets = () => {
const { ref: projectRef } = useParams()
const router = useRouter()
const [filterString, setFilterString] = useState('')
const [bucketForDeletion, setBucketForDeletion] = useState<{
vectorBucketName: string
creationTime: string
} | null>(null)
const { data, isLoading: isLoadingBuckets } = useVectorBucketsQuery({ projectRef })
const bucketsList = data?.vectorBuckets ?? []
@@ -51,13 +36,25 @@ export const VectorsBuckets = () => {
bucket.vectorBucketName.toLowerCase().includes(filterString.toLowerCase())
)
const handleBucketNavigation = (
bucketName: string,
event: React.MouseEvent | React.KeyboardEvent
) => {
const url = `/project/${projectRef}/storage/vectors/buckets/${encodeURIComponent(bucketName)}`
if (event.metaKey || event.ctrlKey) {
window.open(url, '_blank')
} else {
router.push(url)
}
}
return (
<ScaffoldSection isFullWidth>
<Admonition
type="warning"
type="note"
layout="horizontal"
className="mb-12 [&>div]:!translate-y-0 [&>svg]:!translate-y-1"
title="Vector buckets are in alpha"
className="-mt-4 mb-8 [&>div]:!translate-y-0 [&>svg]:!translate-y-1"
title="Private alpha"
actions={
<Button asChild type="default" icon={<ExternalLink />}>
<a
@@ -66,15 +63,16 @@ export const VectorsBuckets = () => {
// [Joshen] To update with Vector specific GH discussion
href="https://github.com/orgs/supabase/discussions/40116"
>
Leave feedback
Share feedback
</a>
</Button>
}
>
<p className="!leading-normal !mb-0">
Expect rapid changes, limited features, and possible breaking updates as we expand access.
<p className="!leading-normal !mb-0 text-balance">
Vector buckets are now in private alpha. Expect rapid changes, limited features, and
possible breaking updates. Please share feedback as we refine the experience and expand
access.
</p>
<p className="!leading-normal !mb-0">Please share feedback as we refine the experience!</p>
</Admonition>
{!isLoadingBuckets && bucketsList.length === 0 ? (
@@ -103,14 +101,21 @@ export const VectorsBuckets = () => {
<Table>
<TableHeader>
<TableRow>
{filteredBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead />
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredBuckets.length === 0 && filterString.length > 0 && (
<TableRow>
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-lighter">
@@ -126,15 +131,18 @@ export const VectorsBuckets = () => {
const created = +bucket.creationTime * 1000
return (
<TableRow key={id}>
<TableRow key={id} className="relative cursor-pointer h-16">
<TableCell className="w-2 pr-1">
<BucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<Link
href={`/project/${projectRef}/storage/vectors/buckets/${encodeURIComponent(name)}`}
title={name}
className="text-link-table-cell"
<p className="whitespace-nowrap max-w-[512px] truncate">{name}</p>
<button
className={cn('absolute inset-0', 'inset-focus')}
onClick={(event) => handleBucketNavigation(name, event)}
>
{name}
</Link>
<span className="sr-only">Go to bucket details</span>
</button>
</TableCell>
<TableCell>
<p className="text-foreground-light">
@@ -145,30 +153,8 @@ export const VectorsBuckets = () => {
</p>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button asChild type="default">
<Link
href={`/project/${projectRef}/storage/vectors/buckets/${encodeURIComponent(name)}`}
>
View contents
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => {
setBucketForDeletion(bucket)
}}
>
<Trash2 size={12} />
<p>Delete bucket</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
@@ -180,13 +166,6 @@ export const VectorsBuckets = () => {
)}
</div>
)}
<DeleteVectorBucketModal
visible={!!bucketForDeletion}
bucketName={bucketForDeletion?.vectorBucketName!}
onCancel={() => setBucketForDeletion(null)}
onSuccess={() => setBucketForDeletion(null)}
/>
</ScaffoldSection>
)
}

View File

@@ -48,10 +48,12 @@ export const PageHeader = ({
{(displayBreadcrumbs.length > 0 ||
(isCompact && (title || primaryActions || secondaryActions))) && (
<div className={cn('flex items-center gap-4', isCompact ? 'justify-between' : 'mb-4')}>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 flex-1 min-w-0">
{breadcrumbs.length > 0 ? (
<Breadcrumb className={cn('text-foreground-muted', isCompact && 'text-base')}>
<BreadcrumbList className={isCompact ? 'text-base' : 'text-xs'}>
<Breadcrumb
className={cn('text-foreground-muted', isCompact && 'text-base', 'min-w-0 flex-1')}
>
<BreadcrumbList className={cn(isCompact ? 'text-base' : 'text-xs', 'min-w-0')}>
{breadcrumbs.map((item, index) => (
<Fragment key={item.label || `breadcrumb-${index}`}>
<BreadcrumbItem>
@@ -81,19 +83,19 @@ export const PageHeader = ({
{isCompact && title && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPageItem>{title}</BreadcrumbPageItem>
<BreadcrumbItem className="min-w-0 flex-1">
<BreadcrumbPageItem className="min-w-0">{title}</BreadcrumbPageItem>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
) : isCompact ? (
title
<div className="min-w-0 flex-1">{title}</div>
) : null}
</div>
{isCompact && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-shrink-0">
{secondaryActions && (
<div className="flex items-center gap-2">{secondaryActions}</div>
)}

View File

@@ -2,6 +2,7 @@ import 'react-data-grid/lib/styles.css'
import 'styles/code.scss'
import 'styles/contextMenu.scss'
import 'styles/editor.scss'
import 'styles/focus.scss'
import 'styles/graphiql-base.scss'
import 'styles/grid.scss'
import 'styles/main.scss'

View File

@@ -1,9 +1,11 @@
import { Edit, Shield } from 'lucide-react'
import { ChevronDown, FolderOpen, Settings, Shield, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
import { DeleteBucketModal } from 'components/interfaces/Storage/DeleteBucketModal'
import { EditBucketModal } from 'components/interfaces/Storage/EditBucketModal'
import { EmptyBucketModal } from 'components/interfaces/Storage/EmptyBucketModal'
import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError'
import { StorageExplorer } from 'components/interfaces/Storage/StorageExplorer/StorageExplorer'
import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket'
@@ -15,14 +17,22 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useStoragePolicyCounts } from 'hooks/storage/useStoragePolicyCounts'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import type { NextPageWithLayout } from 'types'
import { Badge, Button } from 'ui'
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
const BucketPage: NextPageWithLayout = () => {
const { bucketId, ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { projectRef } = useStorageExplorerStateSnapshot()
const { bucket, error, isSuccess, isError } = useSelectedBucket()
const [showEditModal, setShowEditModal] = useState(false)
const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null)
const { getPolicyCount } = useStoragePolicyCounts(bucket ? [bucket as Bucket] : [])
const policyCount = bucket ? getPolicyCount(bucket.id) : 0
@@ -50,9 +60,13 @@ const BucketPage: NextPageWithLayout = () => {
isCompact
className="[&>div:first-child]:!border-b-0" // Override the border-b from ScaffoldContainer
title={
<div className="flex items-center gap-2">
<span>{bucket.name}</span>
{bucket.public && <Badge variant="warning">Public</Badge>}
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="truncate">{bucket.name}</span>
{bucket.public && (
<Badge variant="warning" size="small" className="flex-shrink-0">
Public
</Badge>
)}
</div>
}
breadcrumbs={[
@@ -77,9 +91,37 @@ const BucketPage: NextPageWithLayout = () => {
>
<Link href={`/project/${ref}/storage/files/policies`}>Policies</Link>
</Button>
<Button type="default" icon={<Edit size={14} />} onClick={() => setShowEditModal(true)}>
Edit bucket
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown size={14} />}>
Edit bucket
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => setModal('edit')}
>
<Settings size={12} />
<p>Bucket settings</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => setModal('empty')}
>
<FolderOpen size={12} />
<p>Empty bucket</p>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() => setModal('delete')}
>
<Trash2 size={12} />
<p>Delete bucket</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
>
@@ -88,11 +130,25 @@ const BucketPage: NextPageWithLayout = () => {
</div>
</PageLayout>
<EditBucketModal
visible={showEditModal}
bucket={bucket}
onClose={() => setShowEditModal(false)}
/>
{bucket && (
<>
<EditBucketModal
visible={modal === 'edit'}
bucket={bucket}
onClose={() => setModal(null)}
/>
<EmptyBucketModal
visible={modal === 'empty'}
bucket={bucket}
onClose={() => setModal(null)}
/>
<DeleteBucketModal
visible={modal === 'delete'}
bucket={bucket}
onClose={() => setModal(null)}
/>
</>
)}
</>
)
}

View File

@@ -0,0 +1,4 @@
/* Focus styles for keyboard navigation on interactive table rows */
.inset-focus {
@apply ease-out duration-100 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-600 focus-visible:rounded-md transition-all;
}

View File

@@ -3,34 +3,32 @@ import * as React from 'react'
import { cn } from '../../../lib/utils/cn'
const badgeVariants = cva(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-opacity-10 whitespace-nowrap',
{
variants: {
variant: {
default: 'bg-surface-200 text-foreground-light border border-strong',
warning: 'bg-warning text-warning border border-warning-500',
success: 'bg-brand text-brand-600 border border-brand-500',
destructive: 'bg-destructive text-destructive-600 border border-destructive-500',
brand: 'bg-brand text-brand-600 border border-brand-500',
secondary:
'bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground',
outline: 'bg-transparent text border border-foreground-muted',
},
size: {
small: 'px-2.5 py-0.5 text-xs',
large: 'px-3 py-0.5 rounded-full text-sm',
},
dot: {
true: '-ml-0.5 mr-1.5 h-2 w-2 rounded-full',
},
const badgeVariants = cva('inline-flex items-center rounded-full font-normal whitespace-nowrap', {
variants: {
variant: {
default: 'bg-surface-75 text-foreground-light border border-strong',
warning: 'bg-warning bg-opacity-10 text-warning border border-warning-500',
success: 'bg-brand bg-opacity-10 text-brand-600 border border-brand-500',
destructive:
'bg-destructive bg-opacity-10 text-destructive-600 border border-destructive-500',
brand: 'bg-brand bg-opacity-10 text-brand-600 border border-brand-500',
secondary:
'bg-secondary bg-opacity-10 hover:bg-secondary/80 border-transparent text-secondary-foreground',
outline: 'bg-transparent text border border-foreground-muted',
},
defaultVariants: {
variant: 'default',
size: 'small',
size: {
small: 'px-2 py-0.5 text-xs',
large: 'px-3 py-0.5 text-sm',
},
}
)
dot: {
true: '-ml-0.5 mr-1.5 h-2 w-2',
},
},
defaultVariants: {
variant: 'default',
size: 'small',
},
})
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -39,7 +37,7 @@ export interface BadgeProps
function Badge({
className,
variant = 'default',
size,
size = 'small',
dot = false,
children,
...props

View File

@@ -161,7 +161,8 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-base leading-none font-normal', className)}
// [Danny] max-w to make space for the close button
className={cn('text-base leading-none font-normal max-w-[calc(100%-1rem)]', className)}
{...props}
/>
))