Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ _Individual asset view_

- Support for batch uploads with drag and drop support
- Edit text fields native to Sanity's asset documents, such as `title`, `description`, `altText` and `originalFilename`
- Browse folder paths in a dedicated sidebar and assign slash-delimited folder paths directly on assets
- View asset metadata and a limited subset of EXIF data, if present
- Tag your assets individually or in bulk
- Manage tags directly within the plugin
Expand All @@ -32,8 +33,8 @@ _Individual asset view_

#### Granular search tools

- Refine your search with any combination of search facets such as filtering by tag name, asset usage, file size, orientation, type (and more)
- Use text search for a quick lookup by title, description and alt text
- Refine your search with any combination of search facets such as filtering by tag name, folder path, asset usage, file size, orientation, type (and more)
- Use text search for a quick lookup by title, description, alt text and folder path

#### Built for large datasets and collaborative editing in mind

Expand Down Expand Up @@ -95,7 +96,6 @@ export default defineConfig({
})
```


### Plugin Config

```ts
Expand All @@ -111,7 +111,7 @@ export default defineConfig({
enabled: true,
// boolean - enables an optional "Credit Line" field in the plugin.
// Used to store credits e.g. photographer, licence information
excludeSources: ['unsplash'],
excludeSources: ['unsplash']
// string | string[] - when used with 3rd party asset sources, you may
// wish to prevent users overwriting the creditLine based on the `source.name`
},
Expand All @@ -125,7 +125,7 @@ export default defineConfig({
}
// Custom components to override default UI (see below)
})
],
]
})
```

Expand Down Expand Up @@ -182,6 +182,7 @@ export function CustomDetails(props) {
<summary>Limitations when using Sanity's GraphQL endpoints</summary>

- Currently, `opt.media.tags` on assets aren't accessible via GraphQL. This is because `opt` is a custom object used by this plugin and not part of Sanity's asset schema.
- The same limitation applies to `opt.media.folder`.

</details>

Expand All @@ -203,7 +204,7 @@ export function CustomDetails(props) {
<details>
<summary>How can I query asset fields I've set in this plugin?</summary>

The following GROQ query will return an image with additional asset text fields as well as an array of tag names.
The following GROQ query will return an image with additional asset text fields, its folder path, and an array of tag names.

Note that tags are namespaced within `opt.media` and tag names are accessed via the `current` property (as they're defined as slugs on the `tag.media` document schema).

Expand All @@ -215,6 +216,7 @@ Note that tags are namespaced within `opt.media` and tag names are accessed via
_type,
altText,
description,
"folder": opt.media.folder,
"tags": opt.media.tags[]->name.current,
title
}
Expand All @@ -234,6 +236,17 @@ Note that tags are namespaced within `opt.media` and tag names are accessed via

</details>

<details>
<summary>How do folders work?</summary>

- Folders are stored as a slash-delimited string at `opt.media.folder` on the asset document
- Folder browsing is driven by the folder paths already assigned to assets, and the sidebar includes derived parent folders for nested paths
- You can move selected assets into the currently open folder from the selection bar, or edit the folder path directly on an individual asset
- This implementation does not create separate folder documents; folders exist when assets are assigned to a path
- Folder paths are normalized before save, so input like `/marketing\\launches/2026/` is stored as `marketing/launches/2026`

</details>

#### Tags

<details>
Expand Down
17 changes: 14 additions & 3 deletions src/components/AssetGridVirtualized/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
import type {CardAssetData, CardUploadData} from '../../types'
import type {CardAssetData, CardFolderData, CardUploadData} from '../../types'
import {memo, forwardRef} from 'react'
import {VirtuosoGrid} from 'react-virtuoso'
import {styled} from 'styled-components'
import useTypedSelector from '../../hooks/useTypedSelector'
import CardAsset from '../CardAsset'
import CardFolder from '../CardFolder'
import CardUpload from '../CardUpload'

type Props = {
items: (CardAssetData | CardUploadData)[]
items: (CardAssetData | CardFolderData | CardUploadData)[]
onLoadMore?: () => void
}

const CARD_HEIGHT = 220
const CARD_WIDTH = 240

const VirtualCell = memo(
({item, selected}: {item: CardAssetData | CardUploadData; selected: boolean}) => {
({
item,
selected
}: {
item: CardAssetData | CardFolderData | CardUploadData
selected: boolean
}) => {
if (item?.type === 'asset') {
return <CardAsset id={item.id} selected={selected} />
}

if (item?.type === 'folder') {
return <CardFolder name={item.name} path={item.path} totalCount={item.totalCount} />
}

if (item?.type === 'upload') {
return <CardUpload id={item.id} />
}
Expand Down
21 changes: 18 additions & 3 deletions src/components/AssetTableVirtualized/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import type {CardAssetData, CardUploadData} from '../../types'
import type {CardAssetData, CardFolderData, CardUploadData} from '../../types'
import {Box} from '@sanity/ui'
import {memo} from 'react'
import {GroupedVirtuoso} from 'react-virtuoso'
import useTypedSelector from '../../hooks/useTypedSelector'
import TableHeader from '../TableHeader'
import TableRowAsset from '../TableRowAsset'
import TableRowFolder from '../TableRowFolder'
import TableRowUpload from '../TableRowUpload'

type Props = {
items: (CardAssetData | CardUploadData)[]
items: (CardAssetData | CardFolderData | CardUploadData)[]
onLoadMore?: () => void
}

const VirtualRow = memo(
({item, selected}: {item: CardAssetData | CardUploadData; selected: boolean}) => {
({
item,
selected
}: {
item: CardAssetData | CardFolderData | CardUploadData
selected: boolean
}) => {
if (item?.type === 'asset') {
return (
<Box style={{height: '100px'}}>
Expand All @@ -22,6 +29,14 @@ const VirtualRow = memo(
)
}

if (item?.type === 'folder') {
return (
<Box style={{height: '100px'}}>
<TableRowFolder name={item.name} path={item.path} totalCount={item.totalCount} />
</Box>
)
}

if (item?.type === 'upload') {
return (
<Box style={{height: '100px'}}>
Expand Down
20 changes: 19 additions & 1 deletion src/components/Browser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import groq from 'groq'
import {useEffect, useState} from 'react'
import {useDispatch} from 'react-redux'
import {type AssetSourceComponentProps, type SanityDocument} from 'sanity'
import {TAG_DOCUMENT_NAME} from '../../constants'
import {FOLDER_DOCUMENT_NAME, TAG_DOCUMENT_NAME} from '../../constants'
import {AssetBrowserDispatchProvider} from '../../contexts/AssetSourceDispatchContext'
import useVersionedClient from '../../hooks/useVersionedClient'
import {assetsActions} from '../../modules/assets'
import {foldersActions} from '../../modules/folders'
import {tagsActions} from '../../modules/tags'
import GlobalStyle from '../../styled/GlobalStyles'
import Controls from '../Controls'
import DebugControls from '../DebugControls'
import Dialogs from '../Dialogs'
import FolderBreadcrumbs from '../FolderBreadcrumbs'
import FolderPanel from '../FolderPanel'
import Header from '../Header'
import Items from '../Items'
import Notifications from '../Notifications'
Expand Down Expand Up @@ -68,9 +71,16 @@ const BrowserContent = ({onClose}: {onClose?: AssetSourceComponentProps['onClose
}
}

const handleFolderUpdate = (_update: MutationEvent) => {
dispatch(foldersActions.fetchRequest())
}

// Fetch assets: first page
dispatch(assetsActions.loadPageIndex({pageIndex: 0}))

// Fetch all folder paths
dispatch(foldersActions.fetchRequest())

// Fetch all tags
dispatch(tagsActions.fetchRequest())

Expand All @@ -88,8 +98,13 @@ const BrowserContent = ({onClose}: {onClose?: AssetSourceComponentProps['onClose
.listen(groq`*[_type == "${TAG_DOCUMENT_NAME}" && !(_id in path("drafts.**"))]`)
.subscribe(handleTagUpdate)

const subscriptionFolder = client
.listen(groq`*[_type == "${FOLDER_DOCUMENT_NAME}" && !(_id in path("drafts.**"))]`)
.subscribe(handleFolderUpdate)

return () => {
subscriptionAsset?.unsubscribe()
subscriptionFolder?.unsubscribe()
subscriptionTag?.unsubscribe()
}
}, [client, dispatch])
Expand All @@ -108,7 +123,10 @@ const BrowserContent = ({onClose}: {onClose?: AssetSourceComponentProps['onClose
{/* Browser Controls */}
<Controls />

<FolderBreadcrumbs />

<Flex flex={1}>
<FolderPanel />
<Flex align="flex-end" direction="column" flex={1} style={{position: 'relative'}}>
<PickedBar />
<Items />
Expand Down
100 changes: 100 additions & 0 deletions src/components/CardFolder/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {Box, Card, Flex, Stack, Text} from '@sanity/ui'
import {useDispatch} from 'react-redux'
import {useColorSchemeValue} from 'sanity'
import {styled, css} from 'styled-components'
import {foldersActions} from '../../modules/folders'
import {getSchemeColor} from '../../utils/getSchemeColor'

type Props = {
name: string
path: string
totalCount: number
}

const CardWrapper = styled(Flex)`
box-sizing: border-box;
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`

const FolderCard = styled(Card)`
cursor: pointer;
height: 100%;
transition: border-color 200ms ease;
width: 100%;

@media (hover: hover) and (pointer: fine) {
&:hover {
border-color: var(--card-border-color);
}
}
`

const FolderGlyph = styled(Box)(
({theme}) => css`
align-items: flex-end;
background: linear-gradient(
180deg,
${theme.sanity.color.spot.yellow} 0%,
${theme.sanity.color.spot.yellow} 100%
);
border-radius: 8px;
display: flex;
height: 72px;
position: relative;
width: 96px;

&::before {
background: ${theme.sanity.color.spot.yellow};
border-radius: 8px 8px 0 0;
content: '';
height: 18px;
left: 0;
position: absolute;
top: -8px;
width: 38px;
}
`
)

const CardFolder = ({name, path, totalCount}: Props) => {
const dispatch = useDispatch()
const scheme = useColorSchemeValue()

return (
<CardWrapper padding={1}>
<FolderCard
onClick={() => dispatch(foldersActions.currentFolderSet({folderPath: path}))}
padding={3}
radius={2}
style={{
background: getSchemeColor(scheme, 'bg'),
border: '1px solid transparent'
}}
>
<Flex direction="column" height="fill" justify="space-between">
<Flex align="center" flex={1} justify="center">
<FolderGlyph />
</Flex>

<Stack space={2}>
<Text
size={1}
style={{lineHeight: '1.35em', minHeight: '2.7em', wordBreak: 'break-word'}}
weight="semibold"
>
{name}
</Text>
<Text muted size={0} style={{lineHeight: '1.2em'}}>
{totalCount} item{totalCount === 1 ? '' : 's'}
</Text>
</Stack>
</Flex>
</FolderCard>
</CardWrapper>
)
}

export default CardFolder
Loading