sanity-plugin-media 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanity-plugin-media",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "This version of `sanity-plugin-media` is for Sanity Studio V3.",
5
5
  "keywords": [
6
6
  "sanity",
@@ -0,0 +1,82 @@
1
+ import {useToast} from '@sanity/ui'
2
+ import {useEffect, useRef} from 'react'
3
+ import {type InputProps} from 'sanity'
4
+ import {applyMediaTags} from '../../utils/applyMediaTags'
5
+ import {useToolOptions} from '../../contexts/ToolOptionsContext'
6
+ import useVersionedClient from '../../hooks/useVersionedClient'
7
+
8
+ type AssetValue = {
9
+ _type: 'image' | 'file'
10
+ asset?: {
11
+ _ref: string
12
+ _type: 'reference'
13
+ }
14
+ }
15
+
16
+ export type AutoTagInputProps = InputProps & {
17
+ mediaTags?: string[]
18
+ }
19
+
20
+ /**
21
+ * Input component that automatically applies media tags when an asset is selected or uploaded.
22
+ *
23
+ * Apply explicitly to image/file fields that should be auto-tagged:
24
+ * ```ts
25
+ * import {AutoTagInput} from 'sanity-plugin-media'
26
+ *
27
+ * defineField({
28
+ * type: 'image',
29
+ * options: { mediaTags: ['product'] }, // also pre-filters the media browser
30
+ * components: { input: AutoTagInput },
31
+ * })
32
+ * ```
33
+ *
34
+ * Pass `mediaTags` as a prop to override or use without `options`:
35
+ * ```ts
36
+ * components: { input: (props) => <AutoTagInput {...props} mediaTags={['product']} /> }
37
+ * ```
38
+ */
39
+ export function AutoTagInput(props: AutoTagInputProps) {
40
+ const {renderDefault, schemaType, value, mediaTags: mediaTagsProp} = props
41
+ const toast = useToast()
42
+
43
+ // Prop takes precedence; fall back to schemaType.options.mediaTags (set for browser pre-filtering)
44
+ const mediaTags =
45
+ mediaTagsProp ?? (schemaType?.options as {mediaTags?: string[]} | undefined)?.mediaTags
46
+
47
+ const client = useVersionedClient()
48
+ const {createTagsOnUpload} = useToolOptions()
49
+
50
+ const prevAssetRef = useRef<string | undefined>(undefined)
51
+ const isInitialMount = useRef(true)
52
+
53
+ const currentAssetRef = (value as AssetValue | undefined)?.asset?._ref
54
+
55
+ useEffect(() => {
56
+ if (isInitialMount.current) {
57
+ isInitialMount.current = false
58
+ prevAssetRef.current = currentAssetRef
59
+ return
60
+ }
61
+
62
+ const previousRef = prevAssetRef.current
63
+ prevAssetRef.current = currentAssetRef
64
+
65
+ if (!mediaTags?.length || !currentAssetRef || currentAssetRef === previousRef) return
66
+
67
+ applyMediaTags({
68
+ client,
69
+ assetId: currentAssetRef,
70
+ mediaTags,
71
+ createTagsOnUpload
72
+ }).catch(err => {
73
+ console.error('[sanity-plugin-media] Failed to apply auto-tags:', err)
74
+ const label = mediaTags.length === 1 ? 'tag' : 'tags'
75
+ toast.push({closable: true, status: 'error', title: `Failed to apply the media ${label} ${mediaTags.join(', ')}`})
76
+ })
77
+ }, [currentAssetRef, mediaTags, client, createTagsOnUpload])
78
+
79
+ return renderDefault(props as InputProps)
80
+ }
81
+
82
+ export default AutoTagInput
@@ -1,15 +1,8 @@
1
- import type {MutationEvent} from '@sanity/client'
2
1
  import {Card, Flex, PortalProvider} from '@sanity/ui'
3
- import type {Asset, Tag} from '../../types'
4
- import groq from 'groq'
5
- import {useEffect, useState} from 'react'
6
- import {useDispatch} from 'react-redux'
2
+ import {useState} from 'react'
7
3
  import {type AssetSourceComponentProps, type SanityDocument} from 'sanity'
8
- import {TAG_DOCUMENT_NAME} from '../../constants'
9
4
  import {AssetBrowserDispatchProvider} from '../../contexts/AssetSourceDispatchContext'
10
5
  import useVersionedClient from '../../hooks/useVersionedClient'
11
- import {assetsActions} from '../../modules/assets'
12
- import {tagsActions} from '../../modules/tags'
13
6
  import GlobalStyle from '../../styled/GlobalStyles'
14
7
  import Controls from '../Controls'
15
8
  import DebugControls from '../DebugControls'
@@ -21,6 +14,7 @@ import PickedBar from '../PickedBar'
21
14
  import ReduxProvider from '../ReduxProvider'
22
15
  import TagsPanel from '../TagsPanel'
23
16
  import UploadDropzone from '../UploadDropzone'
17
+ import {useBrowserInit} from './useBrowserInit'
24
18
 
25
19
  type Props = {
26
20
  assetType?: AssetSourceComponentProps['assetType']
@@ -28,71 +22,20 @@ type Props = {
28
22
  onClose?: AssetSourceComponentProps['onClose']
29
23
  onSelect?: AssetSourceComponentProps['onSelect']
30
24
  selectedAssets?: AssetSourceComponentProps['selectedAssets']
25
+ schemaType?: AssetSourceComponentProps['schemaType']
31
26
  }
32
27
 
33
- const BrowserContent = ({onClose}: {onClose?: AssetSourceComponentProps['onClose']}) => {
28
+ const BrowserContent = ({
29
+ onClose,
30
+ schemaType
31
+ }: {
32
+ onClose?: AssetSourceComponentProps['onClose']
33
+ schemaType?: AssetSourceComponentProps['schemaType']
34
+ }) => {
34
35
  const client = useVersionedClient()
35
36
  const [portalElement, setPortalElement] = useState<HTMLDivElement | null>(null)
36
- const dispatch = useDispatch()
37
-
38
- useEffect(() => {
39
- const handleAssetUpdate = (update: MutationEvent) => {
40
- const {documentId, result, transition} = update
41
-
42
- if (transition === 'appear') {
43
- dispatch(assetsActions.listenerCreateQueue({asset: result as Asset}))
44
- }
45
-
46
- if (transition === 'disappear') {
47
- dispatch(assetsActions.listenerDeleteQueue({assetId: documentId}))
48
- }
49
-
50
- if (transition === 'update') {
51
- dispatch(assetsActions.listenerUpdateQueue({asset: result as Asset}))
52
- }
53
- }
54
-
55
- const handleTagUpdate = (update: MutationEvent) => {
56
- const {documentId, result, transition} = update
57
-
58
- if (transition === 'appear') {
59
- dispatch(tagsActions.listenerCreateQueue({tag: result as Tag}))
60
- }
61
-
62
- if (transition === 'disappear') {
63
- dispatch(tagsActions.listenerDeleteQueue({tagId: documentId}))
64
- }
65
-
66
- if (transition === 'update') {
67
- dispatch(tagsActions.listenerUpdateQueue({tag: result as Tag}))
68
- }
69
- }
70
-
71
- // Fetch assets: first page
72
- dispatch(assetsActions.loadPageIndex({pageIndex: 0}))
73
-
74
- // Fetch all tags
75
- dispatch(tagsActions.fetchRequest())
76
-
77
- // Listen for asset and tag changes in published documents.
78
- // Remember that Sanity listeners ignore joins, order clauses and projections!
79
- // Also note that changes to the selected document (if present) will automatically re-load the media plugin
80
- // due to the desk pane re-rendering.
81
- const subscriptionAsset = client
82
- .listen(
83
- groq`*[_type in ["sanity.fileAsset", "sanity.imageAsset"] && !(_id in path("drafts.**"))]`
84
- )
85
- .subscribe(handleAssetUpdate)
86
-
87
- const subscriptionTag = client
88
- .listen(groq`*[_type == "${TAG_DOCUMENT_NAME}" && !(_id in path("drafts.**"))]`)
89
- .subscribe(handleTagUpdate)
90
37
 
91
- return () => {
92
- subscriptionAsset?.unsubscribe()
93
- subscriptionTag?.unsubscribe()
94
- }
95
- }, [client, dispatch])
38
+ useBrowserInit(client, schemaType)
96
39
 
97
40
  return (
98
41
  <PortalProvider element={portalElement}>
@@ -137,7 +80,7 @@ const Browser = (props: Props) => {
137
80
  >
138
81
  <AssetBrowserDispatchProvider onSelect={props?.onSelect}>
139
82
  <GlobalStyle />
140
- <BrowserContent onClose={props?.onClose} />
83
+ <BrowserContent onClose={props?.onClose} schemaType={props?.schemaType} />
141
84
  </AssetBrowserDispatchProvider>
142
85
  </ReduxProvider>
143
86
  )
@@ -0,0 +1,126 @@
1
+ import type {MutationEvent, SanityClient} from '@sanity/client'
2
+ import groq from 'groq'
3
+ import {useEffect} from 'react'
4
+ import {useDispatch, useSelector} from 'react-redux'
5
+ import type {AssetSourceComponentProps} from 'sanity'
6
+ import type {Dispatch} from 'redux'
7
+
8
+ import {inputs} from '../../config/searchFacets'
9
+ import {TAG_DOCUMENT_NAME} from '../../constants'
10
+ import {searchActions} from '../../modules/search'
11
+ import {tagsActions} from '../../modules/tags'
12
+ import type {RootReducerState} from '../../modules/types'
13
+ import type {Asset, Tag} from '../../types'
14
+ import {assetsActions} from '../../modules/assets'
15
+
16
+ function getMediaTagNames(schemaType?: AssetSourceComponentProps['schemaType']): string[] {
17
+ const mediaTags = (schemaType?.options as {mediaTags?: string[]} | undefined)?.mediaTags
18
+ if (!mediaTags?.length) return []
19
+ const unique = new Set(
20
+ mediaTags.map(t => t?.trim()).filter((t): t is string => Boolean(t?.length))
21
+ )
22
+ return Array.from(unique)
23
+ }
24
+
25
+ function createAssetHandler(dispatch: Dispatch) {
26
+ return (update: MutationEvent) => {
27
+ const {documentId, result, transition} = update
28
+
29
+ switch (transition) {
30
+ case 'appear':
31
+ dispatch(assetsActions.listenerCreateQueue({asset: result as Asset}))
32
+ break
33
+ case 'disappear':
34
+ dispatch(assetsActions.listenerDeleteQueue({assetId: documentId}))
35
+ break
36
+ case 'update':
37
+ dispatch(assetsActions.listenerUpdateQueue({asset: result as Asset}))
38
+ break
39
+ default:
40
+ break
41
+ }
42
+ }
43
+ }
44
+
45
+ function createTagHandler(dispatch: Dispatch) {
46
+ return (update: MutationEvent) => {
47
+ const {documentId, result, transition} = update
48
+
49
+ switch (transition) {
50
+ case 'appear':
51
+ dispatch(tagsActions.listenerCreateQueue({tag: result as Tag}))
52
+ break
53
+ case 'disappear':
54
+ dispatch(tagsActions.listenerDeleteQueue({tagId: documentId}))
55
+ break
56
+ case 'update':
57
+ dispatch(tagsActions.listenerUpdateQueue({tag: result as Tag}))
58
+ break
59
+ default:
60
+ break
61
+ }
62
+ }
63
+ }
64
+
65
+ export function useBrowserInit(
66
+ client: SanityClient,
67
+ schemaType?: AssetSourceComponentProps['schemaType']
68
+ ): void {
69
+ const dispatch = useDispatch()
70
+ const tagsByIds = useSelector((state: RootReducerState) => state.tags.byIds)
71
+ const tagsFetchCount = useSelector((state: RootReducerState) => state.tags.fetchCount)
72
+
73
+ const tagNames = getMediaTagNames(schemaType)
74
+ const hasMediaTags = tagNames.length > 0
75
+
76
+ useEffect(() => {
77
+ if (!hasMediaTags) {
78
+ dispatch(searchActions.facetsClear())
79
+ }
80
+
81
+ dispatch(tagsActions.fetchRequest())
82
+
83
+ const assetSubscription = client
84
+ .listen(
85
+ groq`*[_type in ["sanity.fileAsset", "sanity.imageAsset"] && !(_id in path("drafts.**"))]`
86
+ )
87
+ .subscribe(createAssetHandler(dispatch))
88
+
89
+ const tagSubscription = client
90
+ .listen(groq`*[_type == "${TAG_DOCUMENT_NAME}" && !(_id in path("drafts.**"))]`)
91
+ .subscribe(createTagHandler(dispatch))
92
+
93
+ return () => {
94
+ assetSubscription.unsubscribe()
95
+ tagSubscription.unsubscribe()
96
+ }
97
+ }, [client, dispatch, hasMediaTags])
98
+
99
+ // When mediaTags are configured, wait for the tag fetch to complete then apply facets.
100
+ // Dispatching clear + add synchronously keeps all actions within assetsSearchEpic's
101
+ // 400ms debounce window, so the browser performs exactly one asset fetch on open.
102
+ useEffect(() => {
103
+ if (!hasMediaTags || tagsFetchCount < 0) return
104
+
105
+ const tagFacetInput = inputs.tag
106
+ if (tagFacetInput.type !== 'searchable') return
107
+
108
+ const resolvedTags = tagNames
109
+ .map(name => Object.values(tagsByIds).find(item => item.tag.name.current === name))
110
+ .filter((item): item is NonNullable<typeof item> => Boolean(item))
111
+
112
+ dispatch(searchActions.facetsClear())
113
+
114
+ for (const tagItem of resolvedTags) {
115
+ dispatch(
116
+ searchActions.facetsAdd({
117
+ facet: {
118
+ ...tagFacetInput,
119
+ operatorType: 'references',
120
+ value: {label: tagItem.tag.name.current, value: tagItem.tag._id}
121
+ }
122
+ })
123
+ )
124
+ }
125
+ }, [tagsFetchCount, hasMediaTags]) // eslint-disable-line react-hooks/exhaustive-deps
126
+ }
@@ -44,7 +44,7 @@ const FormBuilderTool = (props: AssetSourceComponentProps) => {
44
44
  zIndex
45
45
  }}
46
46
  >
47
- <Browser document={currentDocument} {...props} />
47
+ <Browser document={currentDocument} schemaType={props.schemaType} {...props} />
48
48
  </Box>
49
49
  </Portal>
50
50
  </PortalProvider>
@@ -5,6 +5,7 @@ import type {DropzoneOptions} from 'react-dropzone'
5
5
  type ContextProps = {
6
6
  dropzone: Pick<DropzoneOptions, 'maxSize'>
7
7
  components: MediaToolOptions['components']
8
+ createTagsOnUpload: boolean
8
9
  creditLine: MediaToolOptions['creditLine']
9
10
  directUploads: MediaToolOptions['directUploads']
10
11
  locales?: Locale[]
@@ -31,6 +32,7 @@ export const ToolOptionsProvider = ({options, children}: PropsWithChildren<Props
31
32
  components: {
32
33
  details: options?.components?.details
33
34
  },
35
+ createTagsOnUpload: options?.createTagsOnUpload ?? true,
34
36
  creditLine: {
35
37
  enabled: options?.creditLine?.enabled || false,
36
38
  excludeSources: creditLineExcludeSources
@@ -41,6 +43,7 @@ export const ToolOptionsProvider = ({options, children}: PropsWithChildren<Props
41
43
  }, [
42
44
  options?.creditLine?.enabled,
43
45
  options?.components,
46
+ options?.createTagsOnUpload,
44
47
  options?.creditLine?.excludeSources,
45
48
  options?.maximumUploadSize,
46
49
  options?.directUploads,
package/src/index.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export {media, mediaAssetSource} from './plugin'
2
- export type {MediaToolOptions} from './types'
2
+ export {AutoTagInput} from './components/AutoTagInputWrapper'
3
+ export type {AutoTagInputProps} from './components/AutoTagInputWrapper'
4
+ export {mediaField} from './utils/mediaField'
5
+ export type {MediaTagsOptions, MediaToolOptions} from './types'
@@ -15,8 +15,13 @@ import type {SUPPORTED_ASSET_TYPES} from '../constants'
15
15
 
16
16
  export type AssetTypes = (typeof SUPPORTED_ASSET_TYPES)[number]
17
17
 
18
+ export type MediaTagsOptions = {
19
+ mediaTags?: string[]
20
+ }
21
+
18
22
  export type MediaToolOptions = {
19
23
  maximumUploadSize?: number
24
+ createTagsOnUpload?: boolean
20
25
  components?: {
21
26
  details?: ComponentType<
22
27
  DetailsProps & {renderDefaultDetails: (props: DetailsProps) => JSX.Element}
@@ -0,0 +1,86 @@
1
+ import type {SanityClient} from '@sanity/client'
2
+ import groq from 'groq'
3
+ import {nanoid} from 'nanoid'
4
+ import {TAG_DOCUMENT_NAME} from '../constants'
5
+ import type {Tag} from '../types'
6
+
7
+ type ApplyMediaTagsOptions = {
8
+ client: SanityClient
9
+ assetId: string
10
+ mediaTags: string[]
11
+ createTagsOnUpload?: boolean
12
+ }
13
+
14
+ // Serialize calls per asset so concurrent fields reading the same asset
15
+ // don't race each other — the second call always runs after the first commits.
16
+ const pendingByAsset = new Map<string, Promise<void>>()
17
+
18
+ export function applyMediaTags(options: ApplyMediaTagsOptions): Promise<void> {
19
+ const {assetId} = options
20
+ const chain = (pendingByAsset.get(assetId) ?? Promise.resolve()).then(() =>
21
+ doApplyMediaTags(options)
22
+ )
23
+ const cleanup = chain
24
+ .catch(() => {})
25
+ .finally(() => {
26
+ if (pendingByAsset.get(assetId) === cleanup) pendingByAsset.delete(assetId)
27
+ })
28
+ pendingByAsset.set(assetId, cleanup)
29
+ return chain
30
+ }
31
+
32
+ async function doApplyMediaTags({
33
+ client,
34
+ assetId,
35
+ mediaTags,
36
+ createTagsOnUpload = true
37
+ }: ApplyMediaTagsOptions): Promise<void> {
38
+ if (!mediaTags || mediaTags.length === 0) return
39
+
40
+ const resolvedTags = await Promise.all(
41
+ mediaTags.map(async tagName => {
42
+ const existingTag = await client.fetch<Tag | null>(
43
+ groq`*[_type == "${TAG_DOCUMENT_NAME}" && name.current == $tagName][0]`,
44
+ {tagName}
45
+ )
46
+ if (existingTag) return existingTag
47
+ if (createTagsOnUpload) {
48
+ const newTag = await client.create({
49
+ _type: TAG_DOCUMENT_NAME,
50
+ name: {_type: 'slug', current: tagName}
51
+ })
52
+ return newTag as Tag
53
+ }
54
+ return null
55
+ })
56
+ )
57
+
58
+ const validTags = resolvedTags.filter((tag): tag is Tag => tag !== null)
59
+ if (validTags.length === 0) return
60
+
61
+ const existing = await client.fetch<{tagIds: string[]} | null>(
62
+ groq`*[_id == $assetId][0]{'tagIds': opt.media.tags[]._ref}`,
63
+ {assetId},
64
+ {useCdn: false} // bypass CDN cache so we see the latest committed tag refs
65
+ )
66
+ const existingIds = new Set(existing?.tagIds ?? [])
67
+
68
+ const tagReferences = validTags
69
+ .filter(tag => !existingIds.has(tag._id))
70
+ .map(tag => ({
71
+ _key: nanoid(),
72
+ _ref: tag._id,
73
+ _type: 'reference' as const,
74
+ _weak: true
75
+ }))
76
+
77
+ if (tagReferences.length === 0) return
78
+
79
+ await client
80
+ .patch(assetId)
81
+ .setIfMissing({opt: {}})
82
+ .setIfMissing({'opt.media': {}})
83
+ .setIfMissing({'opt.media.tags': []})
84
+ .append('opt.media.tags', tagReferences)
85
+ .commit()
86
+ }
@@ -0,0 +1,72 @@
1
+ import type {
2
+ FieldDefinitionBase,
3
+ FileDefinition,
4
+ ImageDefinition,
5
+ WidenInitialValue,
6
+ WidenValidation
7
+ } from 'sanity'
8
+ import {AutoTagInput} from '../components/AutoTagInputWrapper'
9
+
10
+ type ImageMediaFieldConfig = Omit<ImageDefinition, 'options'> &
11
+ FieldDefinitionBase & {
12
+ name: string
13
+ mediaTags: string[]
14
+ options?: ImageDefinition['options']
15
+ }
16
+
17
+ type FileMediaFieldConfig = Omit<FileDefinition, 'options'> &
18
+ FieldDefinitionBase & {
19
+ name: string
20
+ mediaTags: string[]
21
+ options?: FileDefinition['options']
22
+ }
23
+
24
+ type ImageMediaFieldResult = Omit<ImageDefinition, 'options'> &
25
+ FieldDefinitionBase & {
26
+ options?: ImageDefinition['options'] & {mediaTags: string[]}
27
+ components: {input: typeof AutoTagInput}
28
+ } & WidenValidation &
29
+ WidenInitialValue
30
+
31
+ type FileMediaFieldResult = Omit<FileDefinition, 'options'> &
32
+ FieldDefinitionBase & {
33
+ options?: FileDefinition['options'] & {mediaTags: string[]}
34
+ components: {input: typeof AutoTagInput}
35
+ } & WidenValidation &
36
+ WidenInitialValue
37
+
38
+ /**
39
+ * Defines an image or file field with automatic media tag application when an asset is selected.
40
+ *
41
+ * Pass `mediaTags` at the top level — they are moved into `options.mediaTags` (for media browser
42
+ * pre-filtering) and wire up {@link AutoTagInput} as the field component automatically:
43
+ * ```ts
44
+ * import {mediaField} from 'sanity-plugin-media'
45
+ *
46
+ * mediaField({
47
+ * name: 'coverImage',
48
+ * type: 'image',
49
+ * mediaTags: ['product-cover'],
50
+ * options: { hotspot: true },
51
+ * })
52
+ * ```
53
+ *
54
+ * For file fields, set `type: 'file'`:
55
+ * ```ts
56
+ * mediaField({ name: 'drawing', type: 'file', mediaTags: ['model-drawing'] })
57
+ * ```
58
+ */
59
+ export function mediaField(config: ImageMediaFieldConfig): ImageMediaFieldResult
60
+ export function mediaField(config: FileMediaFieldConfig): FileMediaFieldResult
61
+ export function mediaField(
62
+ config: ImageMediaFieldConfig | FileMediaFieldConfig
63
+ ): ImageMediaFieldResult | FileMediaFieldResult {
64
+ const {mediaTags, options, components, ...rest} = config as ImageMediaFieldConfig & {
65
+ components?: Record<string, unknown>
66
+ }
67
+ return {
68
+ ...rest,
69
+ options: {...options, mediaTags},
70
+ components: {...components, input: AutoTagInput}
71
+ } as unknown as ImageMediaFieldResult
72
+ }