Skip to content

Instantly share code, notes, and snippets.

@proteye
Last active April 3, 2024 06:53
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save proteye/b9fc465caeea474fef3873b7297d58ad to your computer and use it in GitHub Desktop.
Save proteye/b9fc465caeea474fef3873b7297d58ad to your computer and use it in GitHub Desktop.
Image uploading in KeystoneJS document editor
import React from 'react'
import { NotEditable, component, fields, FormField } from '@keystone-6/fields-document/component-blocks'
import { TAny } from '../types'
import { TImageFieldData, TImageFieldOptions, TImageFieldValue } from './types'
import { ImageUploader } from './components/ImageUploader'
const customFields = {
image({ listKey }: TImageFieldOptions): FormField<TImageFieldValue, TImageFieldOptions> {
return {
kind: 'form',
Input({ value, onChange }) {
return <ImageUploader listKey={listKey} defaultValue={value} mode="edit" onChange={onChange} />
},
options: { listKey },
defaultValue: null,
validate(value) {
return typeof value === 'object'
},
}
},
}
export const componentBlocks: TAny = {
image: component({
preview: ({ fields }) => (
<NotEditable>
<ImageUploader
listKey={fields.image.options.listKey}
defaultValue={fields.imageRel.value?.data as TImageFieldData}
imageAlt={fields.imageAlt.value}
onChange={fields.image.onChange}
onImageAltChange={fields.imageAlt.onChange}
onRelationChange={fields.imageRel.onChange}
/>
</NotEditable>
),
label: 'Image',
schema: {
imageAlt: fields.text({
label: 'Image Alt',
defaultValue: '',
}),
image: customFields.image({
listKey: 'Image',
}),
imageRel: fields.relationship({
listKey: 'Image',
label: 'Image Relation',
selection: 'id, image { url }',
}),
},
chromeless: true,
}),
}
/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx } from '@keystone-ui/core'
import { FC } from 'react'
import styles from './styles'
import { IImageUploaderProps } from './types'
import useBase from './useBase'
export const ImageUploader: FC<IImageUploaderProps> = (props) => {
const { altText, imageSrc, loading, isShowLabel, isShowImage, handleAltTextChange, handleUploadChange } =
useBase(props)
const { mode } = props
return (
<div css={styles.container(mode)}>
<label id="file" css={styles.imageUploader(isShowImage)}>
{isShowLabel && <span>🖱 Click to select a file...</span>}
{loading && <span>Loading...</span>}
<input
autoComplete="off"
type="file"
accept={'image/*'}
style={{ display: 'none' }}
onChange={handleUploadChange}
/>
<img
src={imageSrc}
alt={altText}
css={styles.imagePreview}
style={{ display: isShowImage ? 'block' : 'none' }}
/>
</label>
{mode === 'preview' && (
<div css={styles.inputWrapper}>
<label>Image Alt:</label>
<input type="text" placeholder="" css={styles.textInput} value={altText} onChange={handleAltTextChange} />
</div>
)}
</div>
)
}
ImageUploader.defaultProps = {
defaultValue: null,
imageAlt: '',
mode: 'preview',
}
/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx } from '@keystone-ui/core'
import { FC } from 'react'
import styles from './styles'
import { IImageUploaderProps } from './types'
import useBase from './useBase'
export const ImageUploader: FC<IImageUploaderProps> = (props) => {
const { altText, imageSrc, loading, isShowLabel, isShowImage, handleAltTextChange, handleUploadChange } =
useBase(props)
const { mode } = props
return (
<div css={styles.container(mode)}>
<label id="file" css={styles.imageUploader(isShowImage)}>
{isShowLabel && <span>🖱 Click to select a file...</span>}
{loading && <span>Loading...</span>}
<input
autoComplete="off"
type="file"
accept={'image/*'}
style={{ display: 'none' }}
onChange={handleUploadChange}
/>
<img
src={imageSrc}
alt={altText}
css={styles.imagePreview}
style={{ display: isShowImage ? 'block' : 'none' }}
/>
</label>
{mode === 'preview' && (
<div css={styles.inputWrapper}>
<label>Image Alt:</label>
<input type="text" placeholder="" css={styles.textInput} value={altText} onChange={handleAltTextChange} />
</div>
)}
</div>
)
}
ImageUploader.defaultProps = {
defaultValue: null,
imageAlt: '',
mode: 'preview',
}
import { HydratedRelationshipData } from '@keystone-6/fields-document/dist/declarations/src/DocumentEditor/component-blocks/api'
import { TImageFieldValue } from '../../types'
export interface IImageUploaderProps {
listKey: string
defaultValue?: TImageFieldValue
imageAlt?: string
mode?: 'edit' | 'preview'
onChange?(value: TImageFieldValue): void
onImageAltChange?(value: string): void
onRelationChange?(value: HydratedRelationshipData): void
}
import { ChangeEvent, useCallback, useState } from 'react'
import { useMutation, gql } from '@keystone-6/core/admin-ui/apollo'
import { useList } from '@keystone-6/core/admin-ui/context'
import { useToasts } from '@keystone-ui/toast'
import { IImageUploaderProps } from './types'
const useBase = ({
listKey,
defaultValue,
imageAlt,
onChange,
onImageAltChange,
onRelationChange,
}: IImageUploaderProps) => {
const [altText, setAltText] = useState(imageAlt ?? '')
const [imageSrc, setImageSrc] = useState(defaultValue?.image?.url ?? '')
const list = useList(listKey)
const toasts = useToasts()
const UPLOAD_IMAGE = gql`
mutation ${list.gqlNames.createMutationName}($file: Upload!) {
${list.gqlNames.createMutationName}(data: { image: { upload: $file } }) {
id, name, type, image { id, extension, filesize, height, width, url }
}
}
`
const [uploadImage, { loading }] = useMutation(UPLOAD_IMAGE)
const uploadFile = useCallback(
async (file: File) => {
try {
return await uploadImage({
variables: { file },
})
} catch (err: any) {
toasts.addToast({
title: `Failed to upload file: ${file.name}`,
tone: 'negative',
message: err.message,
})
}
return null
},
[toasts, uploadImage],
)
const handleAltTextChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.currentTarget
setAltText(value)
onImageAltChange?.(value)
},
[onImageAltChange],
)
const handleUploadChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.currentTarget.files?.[0]
const src = selectedFile ? URL.createObjectURL(selectedFile) : ''
setImageSrc(src)
if (selectedFile) {
const result = await uploadFile(selectedFile)
const uploadedImage = result?.data?.createImage
onChange?.({ id: uploadedImage.id })
if (onRelationChange) {
setTimeout(
() => onRelationChange({ id: uploadedImage.id, label: uploadedImage.name, data: uploadedImage }),
0,
)
}
}
},
[onChange, onRelationChange],
)
return {
altText,
imageSrc,
loading,
isShowLabel: !loading && !imageSrc,
isShowImage: !loading && !!imageSrc,
handleAltTextChange,
handleUploadChange,
}
}
export default useBase
@wenisman
Copy link

wenisman commented Dec 14, 2022

The gist is a great starting block, however you have Styles.ts code misplaced with the ImageUploader, and the type of TImageFieldValue is missing.

@proteye
Copy link
Author

proteye commented Dec 14, 2022

@wenisman
Copy link

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment