Skip to content

Instantly share code, notes, and snippets.

@bobinska-dev
Last active December 8, 2023 03:22
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bobinska-dev/103e589ffc254a3b13f3965423f41fed to your computer and use it in GitHub Desktop.
Save bobinska-dev/103e589ffc254a3b13f3965423f41fed to your computer and use it in GitHub Desktop.
Sanity Guide enriched images in the studio
// This is the query you can use in your front-end to get all values from the example
export const imageQuery = groq`*[_type == "sanity.imageAsset" && _id == $imageId ][0]{
_id,
title,
description,
'altText': coalesce(
@.asset->.altText,
'Image of: ' + @.asset->title,
''
),
'imageDimensions': @.asset->metadata.dimensions,
'blurHashURL': @.asset->metadata.lqip
}`
// or in another query as a join to be able to use the metadata before requesting the optimised image through @sanity/image-url
export const pageQuery = groq`
*[_type == "page" && slug.current == $slug][0]{
_id,
description,
title,
_type,
'slug': slug.current,
'image': image {
...,
'title': @.asset->.title,
'altText': coalesce(
@.asset->.altText,
'Image of Work: ' + @.asset->title,
''),
'description': @.asset->.description
'imageDimensions': @.asset->metadata.dimensions,
'blurHashURL': @.asset->metadata.lqip
}
}
`
// custom handler to patch changes to the document and the sanity image asset
import { GlobalMetadataHandlerProps } from '../types'
/** ## Handler for handleGlobalMetadata being patched after confirmation
*
* when the confirm edit Button is pressed, we send the mutation to the content lake and patch the new data into the Sanity ImageAsset.
*
* We also add a toast notification to let the user know what went wrong.
*/
export const handleGlobalMetadataConfirm = (
props: GlobalMetadataHandlerProps
) => {
const { sanityImage, toast } = props
/** Make sure there is a image _id passed down */
sanityImage._id
? patchImageData(props)
: toast.push({
status: 'error',
title: `No image found!`,
description: `Metadata was not added to the asset because there is no _id... `,
})
}
/** ### Data patching via patchImageData
*
* We also add a toast notification to let the user know what succeeded.
*/
const patchImageData = ({
docId,
sanityImage,
toast,
client,
onClose,
changed,
imagePath,
}: GlobalMetadataHandlerProps) => {
// create an object with the values that should be set
const valuesToSet = Object.entries(sanityImage).reduce(
(acc, [key, value]) => {
if (value === '') {
return acc
}
return {
...acc,
[key]: value,
}
},
{}
)
// create an array of key strings (field names) of fields to be unset
const valuesToUnset = Object.entries(sanityImage).reduce(
(acc, [key, value]) => {
if (value === '') {
return [...acc, key]
}
return acc
},
[]
)
client
.patch(sanityImage._id as string)
.set(valuesToSet)
.unset(valuesToUnset)
.commit(/* {dryRun: true} */) //If you want to test this script first, you can use the dryRun option to see what would happen without actually committing the changes to the content lake.
.then((res) =>
toast.push({
status: 'success',
title: `Success!`,
description: `Metadata added to asset with the _id ${res._id}`,
})
)
.then(() => {
client
.patch(docId)
.set({ [`${imagePath}.changed`]: !changed })
.commit()
})
.finally(() => onClose())
.catch((err) => console.error(err))
}
import {
Button,
Card,
Dialog,
Flex,
Label,
Stack,
TextInput,
useToast,
} from '@sanity/ui'
import { ComponentType, useCallback, useEffect, useState } from 'react'
import { Subscription } from 'rxjs'
import {
ImageValue,
ObjectInputProps,
ObjectSchemaType,
pathToString,
useClient,
useFormValue,
} from 'sanity'
import Metadata from './components/Metadata'
import { MetadataImage } from './types'
import { handleGlobalMetadataConfirm } from './utils/handleGlobalMetadataChanges'
import { sleep } from './utils/sleep'
const ImageInput: ComponentType<
ObjectInputProps<ImageValue, ObjectSchemaType>
> = (props: ObjectInputProps<ImageValue>) => {
/*
* Variables and Definitions used in the component
*/
/** Fields to be displayed in the metadata modal */
const fields = props.schemaType?.options?.requiredFields ?? []
/** # Toast component
*
* Use the toast component to display a message to the user.
* more {@link https://www.sanity.io/ui/docs/component/toast}
*
* ## Usage
*
* ```ts
* .then((res) => toast.push({
* status: 'error',
* title: <TITLE STRING>,
* description: <DESCRIPTION STRING>,
* })
* )
* ```
*/
const toast = useToast()
/** Document values via Sanity Hooks */
const docId = useFormValue(['_id']) as string
/** image change boolean for each patch to toggle for revalidation on document */
const changed =
(useFormValue([pathToString(props.path), 'changed']) as boolean) ?? false
/** Image ID from the props */
const imageId = props.value?.asset?._ref
/** Sanity client */
const client = useClient({ apiVersion: '2023-03-25' })
/*
* Dialog states & callbacks
*/
/** Sanity Image Data State
*
* Referenced data, fetched from image asset via useEffect and listener (subscription)
*
* */
const [sanityImage, setSanityImage] = useState<MetadataImage>(null)
/** get object for error state from required values in `fields` array
* @see {@link fields}
*/
const fieldsToValidate = fields.reduce((acc, field) => {
if (field.required) {
return { ...acc, [field.name]: false }
}
return acc
}, {})
/** Error state used for disabling buttons in case of missing data */
const [validationStatus, setValidationStatus] = useState(fieldsToValidate)
/** Dialog (dialog-image-defaults) */
const [open, setOpen] = useState(false)
const onClose = useCallback(() => setOpen(false), [])
const onOpen = useCallback(() => setOpen(true), [])
const [collapsed, setCollapsed] = useState(true)
const onCollapse = useCallback(() => setCollapsed(true), [])
const onExpand = useCallback(() => setCollapsed(false), [])
/** Handle Change from Inputs in the metadata modal
*
* @param {string} event is the value of the input
* @param {string} field is the input name the change is made in (corresponds with the field name on the sanity.imageAsset type)
*/
const handleChange = useCallback(
(event: string, field: string) => {
/* unset value */
event === ''
? setSanityImage((prevSanityImage) => ({
...prevSanityImage,
[field]: '',
}))
: setSanityImage((prevSanityImage) => ({
...prevSanityImage,
[field]: event,
}))
const isFieldToValidate = fieldsToValidate[field] !== undefined
isFieldToValidate &&
setValidationStatus((prevValidationStatus) => ({
...prevValidationStatus,
[field]: event.trim() !== '' ? true : false,
}))
},
[fieldsToValidate]
)
/*
* Fetching the global image data
*/
useEffect(() => {
/** Initialising the subscription
*
* we need to initialise the subscription so we can then listen for changes
*/
let subscription: Subscription
const query = `*[_type == "sanity.imageAsset" && _id == $imageId ][0]{
_id,
altText,
title,
description,
}`
const params = { imageId: imageId }
const fetchReference = async (listening = false) => {
/** Debouncing the listener
*/
listening && (await sleep(1500))
/** Fetching the data */
await client
.fetch(query, params)
.then((res) => {
setSanityImage(res)
// check if all required fields are filled by checking if validationStatus fields have values in res
const resValidationStatus = Object.entries(res).reduce(
(acc, [key, value]) => {
if (value && fieldsToValidate[key] !== undefined) {
return { ...acc, [key]: true }
}
if (!value && fieldsToValidate[key] !== undefined) {
return { ...acc, [key]: false }
}
return acc
},
{}
)
setValidationStatus(resValidationStatus)
})
.catch((err) => {
console.error(err.message)
})
}
/** since we store our referenced data in a state we need to make sure, we also listen to changes */
const listen = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
subscription = client
.listen(query, params, { visibility: 'query' })
.subscribe(() => fetchReference(true))
}
/** we only want to run the fetchReference function if we have a imageId (from the context) */
imageId ? fetchReference().then(listen) : setSanityImage(null as any)
/** and then we need to cleanup after ourselves, so we don't get any memory leaks */
return function cleanup() {
if (subscription) {
subscription.unsubscribe()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageId, client])
/** Input fields based on the `fields` array
*
* @see {@link fields}
*/
const inputs = fields.map((field) => {
return (
<Card paddingBottom={4} key={field.name}>
<label>
<Stack space={3}>
<Label muted size={1}>
{field.title}
</Label>
<TextInput
id="imageTitle"
fontSize={2}
onChange={(event) =>
handleChange(event.currentTarget.value, field.name)
}
placeholder={field.title}
value={sanityImage ? (sanityImage[field.name] as string) : ''}
required={field.required}
/>
</Stack>
</label>
</Card>
)
})
return (
<div>
{/* * * DEFAULT IMAGE INPUT * * *
*/}
{props.renderDefault(props)}
{/* * * METADATA PREVIEW DISPLAYED UNDERNEATH INPUT * * *
*/}
<Stack paddingY={3}>
{sanityImage && (
<Stack space={3} paddingBottom={2}>
<Metadata title="Title" value={sanityImage?.title} />
<Metadata title="Alt Text" value={sanityImage?.altText} />
<Metadata title="Description" value={sanityImage?.description} />
</Stack>
)}
{/* * * BUTTON TO OPEN EDIT MODAL * * *
*/}
<Flex paddingY={3}>
<Button
mode="ghost"
onClick={onOpen}
disabled={imageId ? false : true}
text="Edit metadata"
/>
</Flex>
</Stack>
{/* * * METADATA INPUT MODAL * *
*/}
{open && (
<Dialog
header="Edit image metadata"
id="dialog-image-defaults"
onClose={onClose}
zOffset={1000}
width={2}
>
<Card padding={5}>
<Stack space={3}>
{/*
* * * INPUT FIELDS * * *
*/}
{inputs}
{/*
* * * SUBMIT BUTTON * * *
*/}
<Button
mode="ghost"
onClick={() =>
handleGlobalMetadataConfirm({
sanityImage,
toast,
client,
onClose,
docId,
changed,
imagePath: pathToString(props.path),
})
}
text="Save global changes"
disabled={
!Object.values(validationStatus).every((isValid) => isValid)
}
/>
</Stack>
</Card>
</Dialog>
)}
</div>
)
}
export default ImageInput
// schemas/image/imageType.ts
import { ImageIcon } from '@sanity/icons'
import { defineField, defineType } from 'sanity'
import ImageInput from './ImageInput'
// redeclare IntrinsicDefinitions for ImageOptions and add `requiredFields` to it
declare module 'sanity' {
export interface ImageOptions {
requiredFields?: string[]
}
}
/** ImageType with Metadata Input
*
* This is a custom image type that allows you to add metadata to the image asset directly.
* These values follow the same logic as the media browser plugin {@link https://www.sanity.io/plugins/sanity-plugin-media}
*
* Since the metadata is added to the image asset, it is available in the frontend via the Sanity CDN.
*
* ## Usage
*
* ```ts
* defineField({
* type: 'imageWithMetadata',
* name: 'metaImage',
* title: 'Meta Image',
* }),
* ```
*
* ## Required Fields
*
* You can set required fields in the options of the image type.
*
* ```ts
* requiredFields: ['title', 'altText'],
* ```
*
* ## Validation
*
* The validation checks if the required fields are set in the image asset.
* Redefining required fields on the field level will override the options.requiredFields in the type schema definition.
*
* ```ts
* defineField({
* type: 'imageWithMetadata',
* name: 'metaImage',
* title: 'Meta Image',
* options: {
* requiredFields: ['title', 'altText', 'description'],
* },
* }),
* ```
*
*/
export const imageType = defineType({
name: 'imageWithMetadata',
type: 'image',
title: 'Image',
description: `Please add the metadata you want to use in the frontend.`,
icon: ImageIcon,
options: {
hotspot: true,
metadata: ['blurhash', 'lqip', 'palette'],
requiredFields: ['title', 'altText'],
},
components: {
input: ImageInput,
},
validation: (Rule) =>
Rule.custom(async (value, context) => {
const client = context.getClient({ apiVersion: '2021-03-25' })
/** Stop validation when no value is set
* If you want to set the image as `required`,
* you should change `true` to "Image is required"
* or another error message
*/
if (!value) return true
/** Get global metadata for set image asset */
const imageMeta = await client.fetch(
'*[_id == $id][0]{description, altText, title}',
{ id: value?.asset?._ref }
)
/** Check if all required fields are set */
const requiredFields = context.type.options.requiredFields
const invalidFields = requiredFields.filter((field: string) => {
return imageMeta[field] === null
})
if (invalidFields.length > 0) {
const message = `Please add a ${invalidFields.join(
', '
)} value to the image!`
return { valid: false, message }
}
return true
}),
fields: [
// we use this to cause revalidation of document when the image is changed
// A listener would also be an option, but more complex
defineField({
type: 'boolean',
name: 'changed',
hidden: true,
}),
],
})
import { visionTool } from '@sanity/vision'
import { defineConfig } from 'sanity'
import { media } from 'sanity-plugin-media'
import { deskTool } from 'sanity/desk'
import { apiVersion, dataset, projectId } from './sanity/env'
import { schema } from './sanity/schema'
import { imageType } from './schemas/image/imageType'
export default defineConfig({
basePath: '/studio',
projectId,
dataset,
schema: {
// Add 'imageType' to the schema types
types: [...YOUR_OTHER_TYPES, imageType],
},
plugins: [
deskTool(),
visionTool({ defaultApiVersion: apiVersion }),
media(),
],
})
import { ToastContextValue } from '@sanity/ui'
import { Image, ImageDimensions, ImageMetadata, SanityClient } from 'sanity'
/** # Image with Metadata
*
* extends the Sanity Image Value with metadata.
* Use the same type in your front end, if you want to use the metadata.
* Use the extendedQuery to get all the metadata from the image asset.
*
* @param {MetadataImage['_id']} _id is the alt text of the image and used as the _ref in image fields
* @param {MetadataImage['title']} title is the alt text (set by media browser)
* @param {MetadataImage['altText']} altText is the alt text (set by media browser)
* @param {MetadataImage['description']} description is the description (set by media browser)
* @param {MetadataImage['imageDimensions']} imageDimensions are the dimensions of the image
* @param {Image['blurHashURL']} blurHashURL is the lqip string of the image metadata
* @param {Image['asset']} asset is the asset of the image
* @see {@link Image} - Sanity Image
*
* ----
*
* ## Sanity Image Type:
*
* ```ts
* declare interface Image {
* [key: string]: unknown
* asset?: Reference
* crop?: ImageCrop
* hotspot?: ImageHotspot
* }
* ```
*
*/
export interface MetadataImage extends Image {
title?: string
altText?: string
description?: string
_id: string
imageDimensions?: ImageDimensions
blurHashURL?: ImageMetadata['lqip']
}
/** # GlobalMetadataHandlerProps
*
* This is the type of the props passed to the global metadata handler.
*
* @param {MetadataImage} sanityImage is the image object with metadata
* @param {ToastContextValue} toast is the toast context from the Sanity UI
* @param {SanityClient} client is the Sanity client
* @param {() => void} onClose is the function to close the dialog
* @param {string} docId is the document id of the document that contains the image
* @param {boolean} changed is a boolean that indicates if the image has changed
* @param {string} imagePath is the path to the image
*
*/
export interface GlobalMetadataHandlerProps {
sanityImage: MetadataImage
toast: ToastContextValue
client: SanityClient
onClose: () => void
docId: string
changed: boolean
imagePath: string
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment