sanity-plugin-media 4.2.0 → 4.3.1
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/README.md +52 -0
- package/dist/index.d.mts +97 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +215 -87
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +218 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/AutoTagInputWrapper/index.tsx +82 -0
- package/src/components/Browser/index.tsx +12 -69
- package/src/components/Browser/useBrowserInit.ts +126 -0
- package/src/components/FormBuilderTool/index.tsx +1 -1
- package/src/contexts/ToolOptionsContext.tsx +3 -0
- package/src/index.ts +4 -1
- package/src/types/index.ts +5 -0
- package/src/utils/applyMediaTags.ts +86 -0
- package/src/utils/mediaField.ts +72 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanity-plugin-media",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"description": "This version of `sanity-plugin-media` is for Sanity Studio V3.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sanity",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"react": "^18.3 || ^19",
|
|
126
126
|
"react-dom": "^18.3 || ^19",
|
|
127
127
|
"react-is": "^18.3 || ^19",
|
|
128
|
-
"sanity": "^3.78 || ^4.0.0-0 || ^5",
|
|
128
|
+
"sanity": "^3.78 || ^4.0.0-0 || ^5 || ^6.0.0-0",
|
|
129
129
|
"styled-components": "^6.1"
|
|
130
130
|
},
|
|
131
131
|
"engines": {
|
|
@@ -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
|
|
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 = ({
|
|
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
|
-
|
|
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
|
|
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'
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
}
|