Last active
January 31, 2025 19:50
-
-
Save bobinska-dev/103e589ffc254a3b13f3965423f41fed to your computer and use it in GitHub Desktop.
Sanity Guide enriched images in the studio
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 | |
} | |
} | |
` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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)) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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, | |
}), | |
], | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | |
], | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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