sanity-plugin-shopify-assets 1.0.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.
@@ -0,0 +1,202 @@
1
+ import {Asset, PageInfo, ShopifyAPIResponse, ShopifyFile} from '../types'
2
+ import {BehaviorSubject, Subscription} from 'rxjs'
3
+ import {Card, Dialog, Flex, Inline, Spinner, Stack, Text, TextInput} from '@sanity/ui'
4
+ import {PatchEvent, set, useProjectId, ObjectInputProps} from 'sanity'
5
+ import React, {useCallback, useEffect, useMemo, useState} from 'react'
6
+
7
+ import DialogHeader from './DialogHeader'
8
+ import {ErrorOutlineIcon} from '@sanity/icons'
9
+ import File from './File'
10
+ import InfiniteScroll from 'react-infinite-scroll-component'
11
+ import PhotoAlbum from 'react-photo-album'
12
+ import {Search} from './ShopifyAssetInput.styled'
13
+ import {search} from '../datastores/shopify'
14
+
15
+ const RESULTS_PER_PAGE = 42
16
+ const PHOTO_SPACING = 2
17
+ const PHOTO_PADDING = 1
18
+
19
+ export interface AssetPickerProps extends ObjectInputProps<Asset> {
20
+ shopifyDomain: string
21
+ isOpen: boolean
22
+ onClose: () => void
23
+ }
24
+
25
+ export default function ShopifyAssetPicker(props: AssetPickerProps) {
26
+ const {isOpen, onClose, shopifyDomain, onChange, schemaType, value} = props
27
+ const projectId = useProjectId()
28
+
29
+ const [error, setError] = useState('')
30
+ const [query, setQuery] = useState('')
31
+ const [searchResults, setSearchResults] = useState<any[]>([])
32
+ const [pageInfo, setPageInfo] = useState<PageInfo>()
33
+ const [isLoading, setIsLoading] = useState(true)
34
+
35
+ const searchSubject$ = useMemo(() => new BehaviorSubject(''), [])
36
+ const cursorSubject$ = useMemo(() => new BehaviorSubject(''), [])
37
+
38
+ useEffect(() => {
39
+ if (!shopifyDomain) setError('Please configure your Shopify domain in the plugin config')
40
+ }, [shopifyDomain])
41
+
42
+ useEffect(() => {
43
+ const searchSubscription: Subscription = search(
44
+ projectId,
45
+ shopifyDomain,
46
+ searchSubject$,
47
+ cursorSubject$,
48
+ RESULTS_PER_PAGE
49
+ ).subscribe({
50
+ next: (results: ShopifyAPIResponse) => {
51
+ setSearchResults((prevResults) => [...prevResults, ...results.assets])
52
+ setPageInfo(results.pageInfo)
53
+ setIsLoading(false)
54
+ },
55
+ error: (err) => {
56
+ setError(
57
+ `${
58
+ err.response.data.message || err.message || 'An error occurred'
59
+ } - check plugin configuration`
60
+ )
61
+ },
62
+ })
63
+
64
+ return () => searchSubscription.unsubscribe()
65
+ }, [searchSubject$, cursorSubject$, shopifyDomain, projectId])
66
+
67
+ const handleSearchTermChanged = useCallback(
68
+ (event: React.ChangeEvent<HTMLInputElement>) => {
69
+ const newQuery = event.currentTarget.value
70
+ setQuery(newQuery)
71
+ setSearchResults([])
72
+ setPageInfo(undefined)
73
+ setIsLoading(true)
74
+
75
+ cursorSubject$.next('')
76
+ searchSubject$.next(newQuery)
77
+ },
78
+ [cursorSubject$, searchSubject$]
79
+ )
80
+
81
+ const handleScollerLoadMore = useCallback(() => {
82
+ setIsLoading(true)
83
+ if (pageInfo) cursorSubject$.next(pageInfo.cursor)
84
+ searchSubject$.next(query)
85
+ }, [cursorSubject$, pageInfo, searchSubject$, query])
86
+
87
+ const handleSelect = useCallback(
88
+ (file: Asset) => {
89
+ file._key = value?._key
90
+ file._type = schemaType.name
91
+ onChange(PatchEvent.from([set(file)]))
92
+ onClose()
93
+ },
94
+ [onChange, onClose, schemaType.name, value?._key]
95
+ )
96
+
97
+ const renderFile = useCallback(
98
+ (fileProps: any) => {
99
+ const {photo, layout} = fileProps
100
+ return (
101
+ <File
102
+ onClick={handleSelect}
103
+ data={photo.data}
104
+ width={layout.width}
105
+ height={layout.height}
106
+ />
107
+ )
108
+ },
109
+ [handleSelect]
110
+ )
111
+
112
+ const handleWidth = useCallback((width: number) => {
113
+ if (width < 300) return 150
114
+ else if (width < 600) return 200
115
+ return 300
116
+ }, [])
117
+
118
+ return (
119
+ <Dialog
120
+ id="shopify-asset-source"
121
+ header={<DialogHeader title="Shopify Assets" shopifyDomain={shopifyDomain} />}
122
+ onClose={onClose}
123
+ open={isOpen}
124
+ width={4}
125
+ >
126
+ <Stack space={3} padding={4}>
127
+ {error ? (
128
+ <Card overflow="hidden" padding={4} radius={2} shadow={1} tone="critical">
129
+ <Flex align="center" gap={3}>
130
+ <Text size={2}>
131
+ <ErrorOutlineIcon />
132
+ </Text>
133
+ <Inline space={2}>
134
+ <Text size={1}>{error}</Text>
135
+ </Inline>
136
+ </Flex>
137
+ </Card>
138
+ ) : (
139
+ <>
140
+ <Card>
141
+ <Search space={3}>
142
+ <Text size={1} weight="semibold">
143
+ Search Shopify for assets
144
+ </Text>
145
+ <TextInput
146
+ label="Search Images"
147
+ placeholder="filename.jpg"
148
+ value={query}
149
+ onChange={handleSearchTermChanged}
150
+ />
151
+ </Search>
152
+ </Card>
153
+ {!isLoading && searchResults.length === 0 && (
154
+ <Text size={1} muted>
155
+ No results found
156
+ </Text>
157
+ )}
158
+ <InfiniteScroll
159
+ dataLength={searchResults.length} // This is important field to render the next data
160
+ next={handleScollerLoadMore}
161
+ hasMore={pageInfo ? pageInfo?.hasNextPage : true}
162
+ scrollThreshold={0.99}
163
+ height="60vh"
164
+ loader={
165
+ <Flex align="center" justify="center" padding={3}>
166
+ <Spinner muted />
167
+ </Flex>
168
+ }
169
+ endMessage={
170
+ <Flex align="center" justify="center" padding={3}>
171
+ <Text size={1} muted>
172
+ No more results
173
+ </Text>
174
+ </Flex>
175
+ }
176
+ >
177
+ {searchResults && (
178
+ <PhotoAlbum
179
+ layout="rows"
180
+ spacing={PHOTO_SPACING}
181
+ padding={PHOTO_PADDING}
182
+ targetRowHeight={handleWidth}
183
+ photos={searchResults.map((file: ShopifyFile) => ({
184
+ src: file?.preview?.url,
185
+ width: file?.preview?.width || 2048,
186
+ height: file?.preview?.height || 2048,
187
+ key: file.id,
188
+ data: file,
189
+ }))}
190
+ renderPhoto={renderFile}
191
+ componentsProps={{
192
+ containerProps: {style: {marginBottom: `${PHOTO_SPACING}px`}},
193
+ }}
194
+ />
195
+ )}
196
+ </InfiniteScroll>
197
+ </>
198
+ )}
199
+ </Stack>
200
+ </Dialog>
201
+ )
202
+ }
@@ -0,0 +1,22 @@
1
+ import React from 'react'
2
+
3
+ const ShopifyIcon = () => {
4
+ return (
5
+ <svg width="18" height="20" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
6
+ <path
7
+ d="M15.3269 3.85113C15.3132 3.75015 15.2258 3.69411 15.1531 3.688C15.081 3.6819 13.6693 3.66026 13.6693 3.66026C13.6693 3.66026 12.4887 2.50392 12.3722 2.38628C12.2555 2.26865 12.0277 2.30417 11.9392 2.3308C11.9381 2.33135 11.7175 2.40016 11.3461 2.51612C11.2839 2.31304 11.1927 2.06335 11.0622 1.81255C10.6419 1.00356 10.0263 0.575752 9.2825 0.574642C9.28142 0.574642 9.28092 0.574642 9.27975 0.574642C9.22808 0.574642 9.17692 0.579636 9.12517 0.584074C9.10317 0.557441 9.08117 0.531362 9.05808 0.505838C8.73408 0.156272 8.31869 -0.0140727 7.82082 0.000908712C6.86027 0.0286521 5.90357 0.72834 5.12787 1.97124C4.58212 2.84572 4.16677 3.94435 4.04904 4.79497C2.94601 5.13953 2.17471 5.38035 2.15766 5.3859C1.60091 5.56235 1.58331 5.57955 1.51069 6.10889C1.45677 6.50895 0 17.8704 0 17.8704L12.2082 20L17.4994 18.6733C17.4994 18.6733 15.3407 3.95212 15.3269 3.85113ZM10.7349 2.707C10.4537 2.79467 10.1342 2.89454 9.78758 3.00274C9.78042 2.51224 9.72267 1.82975 9.496 1.23992C10.2249 1.3792 10.5836 2.21095 10.7349 2.707ZM9.14883 3.20249C8.509 3.40225 7.81091 3.62031 7.11058 3.83892C7.30753 3.0782 7.68107 2.32081 8.13989 1.8242C8.31044 1.63943 8.54917 1.43358 8.832 1.31594C9.09767 1.87525 9.15542 2.66705 9.14883 3.20249ZM7.84007 0.645665C8.06562 0.640671 8.25542 0.690609 8.41775 0.798253C8.15805 0.9342 7.90718 1.12951 7.67172 1.38419C7.06162 2.04448 6.594 3.06932 6.4075 4.0581C5.826 4.23954 5.25715 4.41766 4.73342 4.58078C5.06405 3.02438 6.35743 0.688944 7.84007 0.645665Z"
8
+ fill="#95BF47"
9
+ />
10
+ <path
11
+ d="M9.276 6.43238L8.66142 8.75117C8.66142 8.75117 7.97598 8.43658 7.16342 8.48817C5.97181 8.56417 5.95916 9.32217 5.97126 9.51242C6.03618 10.5495 8.74125 10.7759 8.89308 13.2051C9.01242 15.1161 7.88796 16.4233 6.26779 16.5265C4.32303 16.6502 3.25246 15.4933 3.25246 15.4933L3.66452 13.7256C3.66452 13.7256 4.74224 14.5457 5.60487 14.4907C6.16821 14.4547 6.36957 13.9924 6.34921 13.6657C6.26448 12.3128 4.06172 12.3927 3.92253 10.17C3.80536 8.29951 5.02337 6.40408 7.71081 6.23318C8.74617 6.16604 9.276 6.43238 9.276 6.43238Z"
12
+ fill="white"
13
+ />
14
+ <path
15
+ d="M15.1536 3.68853C15.0815 3.68243 13.6698 3.66078 13.6698 3.66078C13.6698 3.66078 12.4893 2.50444 12.3726 2.38681C12.3292 2.34298 12.2703 2.32023 12.2087 2.31079L12.2093 19.9994L17.4999 18.6733C17.4999 18.6733 15.3412 3.95264 15.3274 3.85166C15.3137 3.75068 15.2257 3.69463 15.1536 3.68853Z"
16
+ fill="#5E8E3E"
17
+ />
18
+ </svg>
19
+ )
20
+ }
21
+
22
+ export default ShopifyIcon
@@ -0,0 +1,50 @@
1
+ import React, {CSSProperties, useCallback, useEffect, MouseEvent} from 'react'
2
+ import videojs, {VideoJsPlayer} from 'video.js'
3
+
4
+ type PlayerKind = 'player' | 'diff'
5
+
6
+ interface VideoProps {
7
+ src: string
8
+ kind: PlayerKind
9
+ }
10
+
11
+ const VideoPlayer = ({src, kind}: VideoProps) => {
12
+ const videoNode = React.useRef<HTMLVideoElement>(null)
13
+ const player = React.useRef<VideoJsPlayer>()
14
+
15
+ useEffect(() => {
16
+ player.current = videojs(videoNode.current ?? '', {
17
+ sources: [{src}],
18
+ controls: true,
19
+ })
20
+
21
+ player.current.src({src})
22
+ }, [src])
23
+
24
+ const stopPropagation = useCallback((event: MouseEvent) => {
25
+ event.stopPropagation()
26
+ }, [])
27
+
28
+ const className: Record<PlayerKind, string> = {
29
+ player: 'video-js vjs-16-9 vjs-big-play-centered',
30
+ diff: 'video-js vjs-layout-tiny vjs-fluid',
31
+ }
32
+
33
+ const style: CSSProperties = {position: 'relative'}
34
+
35
+ return (
36
+ <div>
37
+ <link href="https://vjs.zencdn.net/7.8.4/video-js.css" rel="stylesheet" />
38
+ <div data-vjs-player>
39
+ <video
40
+ onClick={stopPropagation}
41
+ style={kind === 'diff' ? style : {}}
42
+ className={className[kind]}
43
+ ref={videoNode}
44
+ />
45
+ </div>
46
+ </div>
47
+ )
48
+ }
49
+
50
+ export default VideoPlayer
@@ -0,0 +1,66 @@
1
+ import {BehaviorSubject, Observable, concat, defer} from 'rxjs'
2
+ import {debounceTime, distinctUntilChanged, map, switchMap, withLatestFrom} from 'rxjs/operators'
3
+
4
+ import axios from 'axios'
5
+
6
+ type SearchSubject = BehaviorSubject<string>
7
+ type CursorSubject = BehaviorSubject<any>
8
+
9
+ const fetchSearch = (
10
+ projectId: string,
11
+ shop: string,
12
+ query: string,
13
+ cursor: string,
14
+ perPage: number
15
+ ): Observable<any> =>
16
+ defer(() =>
17
+ axios.get(
18
+ `https://${projectId}.api.sanity.io/v1/shopify/assets/production?shop=${shop}&query=${encodeURIComponent(
19
+ query
20
+ )}${cursor && `&cursor=${cursor}`}&limit=${perPage}`,
21
+ {
22
+ withCredentials: true,
23
+ method: 'GET',
24
+ }
25
+ )
26
+ ).pipe(map((result) => result.data))
27
+
28
+ const fetchList = (
29
+ projectId: string,
30
+ shop: string,
31
+ cursor: string,
32
+ perPage: number
33
+ ): Observable<any> =>
34
+ defer(() =>
35
+ axios.get(
36
+ `https://${projectId}.api.sanity.io/v1/shopify/assets/production?shop=${shop}${
37
+ cursor && `&cursor=${cursor}`
38
+ }&limit=${perPage}`,
39
+ {
40
+ withCredentials: true,
41
+ method: 'GET',
42
+ }
43
+ )
44
+ ).pipe(map((result) => result.data))
45
+
46
+ export const search = (
47
+ projectId: string,
48
+ shop: string,
49
+ query: SearchSubject,
50
+ cursor: CursorSubject,
51
+ resultsPerPage: number
52
+ ): Observable<any> => {
53
+ return concat(
54
+ query.pipe(
55
+ withLatestFrom(cursor),
56
+ debounceTime(500),
57
+ distinctUntilChanged(),
58
+ switchMap(([q, c]) => {
59
+ if (q) {
60
+ return fetchSearch(projectId, shop, q, c, resultsPerPage).pipe(distinctUntilChanged())
61
+ }
62
+ return fetchList(projectId, shop, c, resultsPerPage)
63
+ })
64
+ )
65
+ )
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ import {shopifyAssetSchema} from './schema/shopifyAssetSchema'
2
+ import {definePlugin, ObjectDefinition} from 'sanity'
3
+ import {PluginConfig} from './types'
4
+
5
+ export * from './types'
6
+
7
+ // enables autocompletion and validation of document options
8
+ declare module 'sanity' {
9
+ export namespace Schema {
10
+ // here we type up our custom schema definition
11
+ export type ShopifyAssetTypeDef = Omit<ObjectDefinition, 'type' | 'fields'> & {
12
+ type: 'shopify.asset'
13
+ options: {
14
+ shopifyDomain: string
15
+ }
16
+ }
17
+ // Adds 'extension-type' as an intrinsic type
18
+ export interface IntrinsicTypeDefinition {
19
+ 'shopify.asset': ShopifyAssetTypeDef
20
+ }
21
+ }
22
+ }
23
+
24
+ export const shopifyAssets = definePlugin<PluginConfig>((config) => {
25
+ return {
26
+ name: 'shopify-asset-schema',
27
+ schema: {
28
+ types: [shopifyAssetSchema(config)],
29
+ },
30
+ }
31
+ })
@@ -0,0 +1,118 @@
1
+ /* eslint-disable */
2
+ import ShopifyAssetInput from '../components/ShopifyAssetInput'
3
+ import AssetDiff from '../components/AssetDiff'
4
+ import AssetPreview from '../components/AssetPreview'
5
+ import {defineField, defineType} from 'sanity'
6
+
7
+ interface ObjectConfig {
8
+ shopifyDomain: string
9
+ }
10
+
11
+ declare module 'sanity' {
12
+ interface ObjectOptions {
13
+ shopifyDomain?: string
14
+ }
15
+ }
16
+
17
+ export const shopifyAssetSchema = (config: ObjectConfig) => {
18
+ const {shopifyDomain} = config
19
+
20
+ return defineType({
21
+ type: 'object',
22
+ name: 'shopify.asset',
23
+ title: 'Shopify Asset',
24
+ options: {
25
+ shopifyDomain,
26
+ },
27
+ fields: [
28
+ defineField({
29
+ type: 'string',
30
+ name: 'filename',
31
+ }),
32
+ defineField({
33
+ type: 'string',
34
+ name: 'id',
35
+ }),
36
+ defineField({
37
+ type: 'object',
38
+ name: 'meta',
39
+ fields: [
40
+ defineField({
41
+ type: 'string',
42
+ name: 'alt',
43
+ }),
44
+ defineField({
45
+ type: 'number',
46
+ name: 'duration',
47
+ }),
48
+ defineField({
49
+ type: 'number',
50
+ name: 'fileSize',
51
+ }),
52
+ defineField({
53
+ type: 'number',
54
+ name: 'height',
55
+ }),
56
+ defineField({
57
+ type: 'number',
58
+ name: 'width',
59
+ }),
60
+ ],
61
+ }),
62
+ defineField({
63
+ type: 'object',
64
+ name: 'preview',
65
+ fields: [
66
+ defineField({
67
+ type: 'number',
68
+ name: 'height',
69
+ }),
70
+ defineField({
71
+ type: 'number',
72
+ name: 'width',
73
+ }),
74
+ defineField({
75
+ type: 'url',
76
+ name: 'url',
77
+ }),
78
+ ],
79
+ }),
80
+ defineField({
81
+ type: 'string',
82
+ name: 'type',
83
+ }),
84
+ defineField({
85
+ type: 'url',
86
+ name: 'url',
87
+ }),
88
+ ],
89
+ ...({
90
+ components: {
91
+ input: ShopifyAssetInput,
92
+ diff: AssetDiff,
93
+ preview: AssetPreview,
94
+ },
95
+ } as {}),
96
+ preview: {
97
+ select: {
98
+ meta: 'meta',
99
+ preview: 'preview',
100
+ url: 'url',
101
+ filename: 'filename',
102
+ type: 'type',
103
+ },
104
+ prepare({url, meta, preview, filename, type}) {
105
+ return {
106
+ title: filename,
107
+ subtitle: type,
108
+ value: {
109
+ url,
110
+ meta,
111
+ preview,
112
+ filename,
113
+ },
114
+ }
115
+ },
116
+ },
117
+ })
118
+ }
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ import {ObjectSchemaType} from 'sanity'
2
+
3
+ export interface PluginConfig {
4
+ /**
5
+ * Your *.myshopify.com domain. Do not include https:// or any path.
6
+ */
7
+ shopifyDomain: string
8
+ }
9
+
10
+ export interface ObjectSchemaWithOptions extends ObjectSchemaType {
11
+ options: {
12
+ shopifyDomain: string
13
+ }
14
+ }
15
+
16
+ type PossibleFileTypes = 'file' | 'image' | 'video'
17
+
18
+ export interface AssetPreviewImage {
19
+ height: number
20
+ url: string
21
+ width: number
22
+ }
23
+
24
+ export interface AssetMeta {
25
+ alt?: string
26
+ duration?: number
27
+ fileSize?: number
28
+ height?: number
29
+ width?: number
30
+ }
31
+
32
+ export interface ShopifyFile {
33
+ meta: AssetMeta
34
+ preview: AssetPreviewImage
35
+ id: string
36
+ type: PossibleFileTypes
37
+ url: string
38
+ }
39
+
40
+ export interface Asset extends ShopifyFile {
41
+ _type?: string
42
+ _key?: string
43
+ filename?: string
44
+ }
45
+
46
+ export interface PageInfo {
47
+ hasNextPage: boolean
48
+ cursor: string
49
+ }
50
+
51
+ export interface ShopifyAPIResponse {
52
+ pageInfo: PageInfo
53
+ assets: ShopifyFile[]
54
+ }
@@ -0,0 +1 @@
1
+ export const extractName = (name: string): string => name?.split('/')?.pop()?.split('?')[0] ?? ''
@@ -0,0 +1,11 @@
1
+ const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
2
+ const {name, version, sanityExchangeUrl} = require('./package.json')
3
+
4
+ export default showIncompatiblePluginDialog({
5
+ name: name,
6
+ versions: {
7
+ v3: version,
8
+ v2: undefined,
9
+ },
10
+ sanityExchangeUrl,
11
+ })