diff --git a/.changeset/real-parents-warn.md b/.changeset/real-parents-warn.md
new file mode 100644
index 00000000000..b7d291a2057
--- /dev/null
+++ b/.changeset/real-parents-warn.md
@@ -0,0 +1,5 @@
+---
+'@keystone-next/admin-ui': minor
+---
+
+Reflected next/image exports from admin-ui for use in other relevant keystone-next packages.
diff --git a/.changeset/rude-parrots-train.md b/.changeset/rude-parrots-train.md
new file mode 100644
index 00000000000..aef1bc2069e
--- /dev/null
+++ b/.changeset/rude-parrots-train.md
@@ -0,0 +1,5 @@
+---
+'@keystone-next/app-basic': patch
+---
+
+Added example of image field support to basic keystone-next example.
diff --git a/.changeset/shaggy-dots-appear.md b/.changeset/shaggy-dots-appear.md
new file mode 100644
index 00000000000..093f875ba7c
--- /dev/null
+++ b/.changeset/shaggy-dots-appear.md
@@ -0,0 +1,5 @@
+---
+'@keystone-next/fields': minor
+---
+
+Added new image field type.
diff --git a/.changeset/stale-balloons-grin.md b/.changeset/stale-balloons-grin.md
new file mode 100644
index 00000000000..2731b1e5ac1
--- /dev/null
+++ b/.changeset/stale-balloons-grin.md
@@ -0,0 +1,5 @@
+---
+'@keystone-next/types': minor
+---
+
+Added types for new images functionality in keystone.
diff --git a/.changeset/tough-ravens-help.md b/.changeset/tough-ravens-help.md
new file mode 100644
index 00000000000..a1052b50b9d
--- /dev/null
+++ b/.changeset/tough-ravens-help.md
@@ -0,0 +1,5 @@
+---
+'@keystone-next/keystone': minor
+---
+
+Added create-image-context, logic for parsing, storing and retrieving image data in keystone core.
diff --git a/examples-next/basic/.gitignore b/examples-next/basic/.gitignore
new file mode 100644
index 00000000000..d298be107f2
--- /dev/null
+++ b/examples-next/basic/.gitignore
@@ -0,0 +1 @@
+public/
\ No newline at end of file
diff --git a/examples-next/basic/keystone.ts b/examples-next/basic/keystone.ts
index 58264c6bb29..adab06d2630 100644
--- a/examples-next/basic/keystone.ts
+++ b/examples-next/basic/keystone.ts
@@ -36,6 +36,7 @@ export default auth.withAuth(
// path: '/admin',
// isAccessAllowed,
},
+ images: { upload: 'local' },
lists,
extendGraphqlSchema,
session: withItemData(
diff --git a/examples-next/basic/schema.ts b/examples-next/basic/schema.ts
index 42327c162af..c90a33b141c 100644
--- a/examples-next/basic/schema.ts
+++ b/examples-next/basic/schema.ts
@@ -7,6 +7,7 @@ import {
timestamp,
select,
virtual,
+ image,
} from '@keystone-next/fields';
import { document } from '@keystone-next/fields-document';
// import { cloudinaryImage } from '@keystone-next/cloudinary';
@@ -34,7 +35,7 @@ export const lists = createSchema({
User: list({
ui: {
listView: {
- initialColumns: ['name', 'posts'],
+ initialColumns: ['name', 'posts', 'avatar'],
},
},
fields: {
@@ -42,13 +43,7 @@ export const lists = createSchema({
name: text({ isRequired: true }),
/** Email is used to log into the system. */
email: text({ isRequired: true, isUnique: true }),
- // avatar: cloudinaryImage({
- // cloudinary: {
- // cloudName: '/* TODO */',
- // apiKey: '/* TODO */',
- // apiSecret: '/* TODO */',
- // },
- // }),
+ avatar: image(),
/** Used to log in. */
password: password(),
/** Administrators have more access to various lists and fields. */
diff --git a/packages-next/admin-ui/image/package.json b/packages-next/admin-ui/image/package.json
new file mode 100644
index 00000000000..f81006d13fa
--- /dev/null
+++ b/packages-next/admin-ui/image/package.json
@@ -0,0 +1,4 @@
+{
+ "main": "dist/admin-ui.cjs.js",
+ "module": "dist/admin-ui.esm.js"
+}
diff --git a/packages-next/admin-ui/package.json b/packages-next/admin-ui/package.json
index a9b6538f77a..93d09b34d7d 100644
--- a/packages-next/admin-ui/package.json
+++ b/packages-next/admin-ui/package.json
@@ -52,7 +52,8 @@
"apollo.tsx",
"context.tsx",
"router.tsx",
- "next-config.ts"
+ "next-config.ts",
+ "image.tsx"
]
},
"repository": "https://github.com/keystonejs/keystone/tree/master/packages-next/admin-ui",
diff --git a/packages-next/admin-ui/src/image.tsx b/packages-next/admin-ui/src/image.tsx
new file mode 100644
index 00000000000..61342a3dff1
--- /dev/null
+++ b/packages-next/admin-ui/src/image.tsx
@@ -0,0 +1,2 @@
+export * from 'next/image';
+export { default } from 'next/image';
diff --git a/packages-next/admin-ui/src/system/generateAdminUI.ts b/packages-next/admin-ui/src/system/generateAdminUI.ts
index 5ab94b65339..d197425b9c0 100644
--- a/packages-next/admin-ui/src/system/generateAdminUI.ts
+++ b/packages-next/admin-ui/src/system/generateAdminUI.ts
@@ -53,6 +53,14 @@ export const generateAdminUI = async (
// Nuke any existing files in our target directory
await fs.remove(projectAdminPath);
+ if (config.images) {
+ const publicDirectory = Path.join(projectAdminPath, 'public');
+ await fs.mkdir(publicDirectory, { recursive: true });
+ const storagePath = Path.resolve(config.images.local?.storagePath ?? './public/images');
+ await fs.mkdir(storagePath, { recursive: true });
+ await fs.symlink(storagePath, Path.join(publicDirectory, 'images'), 'junction');
+ }
+
// Write out the files configured by the user
const userPages = config.ui?.getAdditionalFiles?.map(x => x(config)) ?? [];
const userFilesToWrite = (await Promise.all(userPages)).flat();
diff --git a/packages-next/fields/package.json b/packages-next/fields/package.json
index d0a06327ba3..ece9706aba5 100644
--- a/packages-next/fields/package.json
+++ b/packages-next/fields/package.json
@@ -7,6 +7,7 @@
"devDependencies": {
"@keystone-next/server-side-graphql-client-legacy": "3.0.1",
"@keystone-next/test-utils-legacy": "16.0.0",
+ "@types/bytes": "^3.1.0",
"mime": "^2.5.2",
"typescript": "^4.2.3"
},
@@ -25,6 +26,7 @@
"@keystone-ui/icons": "^2.0.1",
"@keystone-ui/loading": "^2.0.1",
"@keystone-ui/modals": "^2.0.1",
+ "@keystone-ui/pill": "^2.0.1",
"@keystone-ui/segmented-control": "^2.0.1",
"@keystone-ui/toast": "^2.0.1",
"@keystone-ui/tooltip": "^2.0.1",
@@ -33,6 +35,8 @@
"@types/react": "^17.0.3",
"apollo-errors": "^1.9.0",
"bcryptjs": "^2.4.3",
+ "bytes": "^3.1.0",
+ "copy-to-clipboard": "^3.3.1",
"cuid": "^2.1.8",
"date-fns": "^2.20.2",
"decimal.js": "^10.2.1",
diff --git a/packages-next/fields/src/Implementation.ts b/packages-next/fields/src/Implementation.ts
index ea21e94ca4d..9132f2c3f5a 100644
--- a/packages-next/fields/src/Implementation.ts
+++ b/packages-next/fields/src/Implementation.ts
@@ -150,8 +150,28 @@ class Field
{
return resolvedData[this.path];
}
+ /**
+ * @param {Object} data
+ * @param {Object} data.resolvedData The incoming item for the mutation with
+ * relationships and defaults already resolved
+ * @param {Object} data.existingItem If this is a updateX mutation, this will
+ * be the existing data in the database
+ * @param {Object} data.context The graphQL context object of the current
+ * request
+ * @param {Object} data.originalInput The raw incoming item from the mutation
+ * (no relationships or defaults resolved)
+ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- async validateInput(args: any) {}
+ async validateInput(data: {
+ resolvedData: Record
;
+ existingItem?: Record;
+ context: KeystoneContext;
+ originalInput: any;
+ listKey: string;
+ fieldPath: P;
+ operation: 'create' | 'update';
+ addFieldValidationError: (msg: string) => void;
+ }) {}
async beforeChange() {}
diff --git a/packages-next/fields/src/index.ts b/packages-next/fields/src/index.ts
index c3756bf0a3e..c10d5c6e760 100644
--- a/packages-next/fields/src/index.ts
+++ b/packages-next/fields/src/index.ts
@@ -11,3 +11,4 @@ export { select } from './types/select';
export { virtual } from './types/virtual';
export { Implementation } from './Implementation';
export type { FieldConfigArgs, FieldExtraArgs } from './Implementation';
+export { image } from './types/image';
diff --git a/packages-next/fields/src/types/image/Implementation.ts b/packages-next/fields/src/types/image/Implementation.ts
new file mode 100644
index 00000000000..f75cc99d5e6
--- /dev/null
+++ b/packages-next/fields/src/types/image/Implementation.ts
@@ -0,0 +1,216 @@
+import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy';
+import { getImageRef, SUPPORTED_IMAGE_EXTENSIONS } from '@keystone-next/utils-legacy';
+import { ImageData, KeystoneContext, BaseKeystoneList } from '@keystone-next/types';
+import { Implementation } from '../../Implementation';
+import { handleImageData } from './handle-image-input';
+
+export class ImageImplementation extends Implementation
{
+ get _supportsUnique() {
+ return false;
+ }
+
+ gqlOutputFields() {
+ return [`${this.path}: ImageFieldOutput`];
+ }
+
+ getGqlAuxTypes() {
+ return [
+ `enum ImageMode {
+ local
+ }
+ input ImageFieldInput {
+ upload: Upload
+ ref: String
+ }
+ enum ImageExtension {
+ ${SUPPORTED_IMAGE_EXTENSIONS.join('\n')}
+ }
+ type ImageFieldOutput {
+ mode: ImageMode!
+ id: ID!
+ filesize: Int!
+ width: Int!
+ height: Int!
+ extension: ImageExtension!
+ ref: String!
+ src: String!
+ }`,
+ ];
+ }
+
+ gqlAuxFieldResolvers() {
+ return {
+ ImageFieldOutput: {
+ src(data: ImageData, _args: any, context: KeystoneContext) {
+ if (!context.images) {
+ throw new Error('Image context is undefined');
+ }
+ return context.images.getSrc(data.mode, data.id, data.extension);
+ },
+ ref(data: ImageData, _args: any, context: KeystoneContext) {
+ if (!context.images) {
+ throw new Error('Image context is undefined');
+ }
+ return getImageRef(data.mode, data.id, data.extension);
+ },
+ },
+ };
+ }
+ // Called on `User.avatar` for example
+ gqlOutputFieldResolvers() {
+ return { [`${this.path}`]: (item: Record
) => item[this.path] };
+ }
+
+ async resolveInput({
+ resolvedData,
+ context,
+ }: {
+ resolvedData: Record
;
+ context: KeystoneContext;
+ }) {
+ const data = resolvedData[this.path];
+ if (data === null) {
+ return null;
+ }
+ if (data === undefined) {
+ return undefined;
+ }
+ const imageData = await handleImageData(data, context);
+ return imageData;
+ }
+
+ gqlUpdateInputFields() {
+ return [`${this.path}: ImageFieldInput`];
+ }
+ gqlCreateInputFields() {
+ return [`${this.path}: ImageFieldInput`];
+ }
+ getBackingTypes() {
+ return { [this.path]: { optional: true, type: 'Record | null' } };
+ }
+}
+
+export class PrismaImageInterface extends PrismaFieldAdapter
{
+ constructor(
+ fieldName: string,
+ path: P,
+ field: ImageImplementation
,
+ listAdapter: PrismaListAdapter,
+ getListByKey: (arg: string) => BaseKeystoneList | undefined,
+ config = {}
+ ) {
+ super(fieldName, path, field, listAdapter, getListByKey, config);
+ // Error rather than ignoring invalid config
+ // We totally can index these values, it's just not trivial. See issue #1297
+ if (this.config.isIndexed) {
+ throw new Error(
+ `The Image field type doesn't support indexes on Prisma. ` +
+ `Check the config for ${this.path} on the ${this.field.listKey} list`
+ );
+ }
+ }
+
+ getPrismaSchema() {
+ return [
+ `${this.path}_filesize Int?`,
+ `${this.path}_extension String?`,
+ `${this.path}_width Int?`,
+ `${this.path}_height Int?`,
+ `${this.path}_mode String?`,
+ `${this.path}_id String?`,
+ ];
+ }
+
+ getQueryConditions() {
+ return {};
+ }
+
+ setupHooks({
+ addPreSaveHook,
+ addPostReadHook,
+ }: {
+ addPreSaveHook: (hook: any) => void;
+ addPostReadHook: (hook: any) => void;
+ }) {
+ const field_path = this.path;
+ const filesize_field = `${this.path}_filesize`;
+ const extension_field = `${this.path}_extension`;
+ const width_field = `${this.path}_width`;
+ const height_field = `${this.path}_height`;
+ const mode_field = `${this.path}_mode`;
+ const id_field = `${this.path}_id`;
+
+ addPreSaveHook(
+ (item: Record
): Record => {
+ if (!Object.prototype.hasOwnProperty.call(item, field_path)) {
+ return item;
+ }
+ if (item[field_path as P] === null) {
+ // If the property exists on the field but is null or falsey
+ // all split fields are null
+ // delete the original field item
+ // return the item
+ const newItem = {
+ [filesize_field]: null,
+ [extension_field]: null,
+ [width_field]: null,
+ [height_field]: null,
+ [id_field]: null,
+ [mode_field]: null,
+ ...item,
+ };
+ delete newItem[field_path];
+ return newItem;
+ } else {
+ const { mode, filesize, extension, width, height, id } = item[field_path];
+
+ const newItem = {
+ [filesize_field]: filesize,
+ [extension_field]: extension,
+ [width_field]: width,
+ [height_field]: height,
+ [id_field]: id,
+ [mode_field]: mode,
+ ...item,
+ };
+
+ delete newItem[field_path];
+
+ return newItem;
+ }
+ }
+ );
+ addPostReadHook(
+ (item: Record): Record => {
+ if (
+ !item[filesize_field] ||
+ !item[extension_field] ||
+ !item[width_field] ||
+ !item[height_field] ||
+ !item[id_field] ||
+ !item[mode_field]
+ ) {
+ item[field_path] = null;
+ return item;
+ }
+ item[field_path] = {
+ filesize: item[filesize_field],
+ extension: item[extension_field],
+ width: item[width_field],
+ height: item[height_field],
+ id: item[id_field],
+ mode: item[mode_field],
+ };
+
+ delete item[filesize_field];
+ delete item[extension_field];
+ delete item[width_field];
+ delete item[height_field];
+ delete item[id_field];
+ delete item[mode_field];
+
+ return item;
+ }
+ );
+ }
+}
diff --git a/packages-next/fields/src/types/image/handle-image-input.ts b/packages-next/fields/src/types/image/handle-image-input.ts
new file mode 100644
index 00000000000..450805355a0
--- /dev/null
+++ b/packages-next/fields/src/types/image/handle-image-input.ts
@@ -0,0 +1,38 @@
+import { KeystoneContext } from '@keystone-next/types';
+import { FileUpload } from 'graphql-upload';
+
+type ImageInput = {
+ upload?: Promise | null;
+ ref?: string | null;
+};
+
+type ValidatedImageInput =
+ | {
+ kind: 'upload';
+ upload: Promise;
+ }
+ | {
+ kind: 'ref';
+ ref: string;
+ };
+
+function validateImageInput({ ref, upload }: ImageInput): ValidatedImageInput {
+ if (ref != null) {
+ if (upload) {
+ throw new Error('Only one of ref and upload can be passed to ImageFieldInput');
+ }
+ return { kind: 'ref', ref };
+ }
+ if (!upload) {
+ throw new Error('Either ref or upload must be passed to ImageFieldInput');
+ }
+ return { kind: 'upload', upload };
+}
+
+export async function handleImageData(input: ImageInput, context: KeystoneContext) {
+ const data = validateImageInput(input);
+ if (data.kind === 'upload') {
+ return context.images!.getDataFromStream((await data.upload).createReadStream());
+ }
+ return context.images!.getDataFromRef(data.ref);
+}
diff --git a/packages-next/fields/src/types/image/index.ts b/packages-next/fields/src/types/image/index.ts
new file mode 100644
index 00000000000..eab740e817e
--- /dev/null
+++ b/packages-next/fields/src/types/image/index.ts
@@ -0,0 +1,22 @@
+import type { FieldType, BaseGeneratedListTypes } from '@keystone-next/types';
+import { resolveView } from '../../resolve-view';
+import type { FieldConfig } from '../../interfaces';
+import { ImageImplementation, PrismaImageInterface } from './Implementation';
+
+export type ImageFieldConfig<
+ TGeneratedListTypes extends BaseGeneratedListTypes
+> = FieldConfig & {
+ isRequired?: boolean;
+};
+
+export const image = (
+ config: ImageFieldConfig = {}
+): FieldType => ({
+ type: {
+ type: 'Image',
+ implementation: ImageImplementation,
+ adapter: PrismaImageInterface,
+ },
+ config,
+ views: resolveView('image/views'),
+});
diff --git a/packages-next/fields/src/types/image/views/Field.tsx b/packages-next/fields/src/types/image/views/Field.tsx
new file mode 100644
index 00000000000..0e006209ef3
--- /dev/null
+++ b/packages-next/fields/src/types/image/views/Field.tsx
@@ -0,0 +1,404 @@
+/** @jsx jsx */
+
+import { jsx, Stack, useTheme, Text } from '@keystone-ui/core';
+import { useToasts } from '@keystone-ui/toast';
+import { TextInput } from '@keystone-ui/fields';
+import { parseImageRef } from '@keystone-next/utils-legacy';
+import copy from 'copy-to-clipboard';
+import bytes from 'bytes';
+import { ReactNode, RefObject, useEffect, useMemo, useRef, useState } from 'react';
+
+import { FieldContainer, FieldLabel } from '@keystone-ui/fields';
+import { Pill } from '@keystone-ui/pill';
+import { Button } from '@keystone-ui/button';
+import { FieldProps } from '@keystone-next/types';
+import NextImage from '@keystone-next/admin-ui/image';
+
+import { ImageValue } from './index';
+
+function useObjectURL(fileData: File | undefined) {
+ let [objectURL, setObjectURL] = useState(undefined);
+ useEffect(() => {
+ if (fileData) {
+ let url = URL.createObjectURL(fileData);
+ setObjectURL(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }
+ }, [fileData]);
+ return objectURL;
+}
+
+const RefView = ({
+ onChange,
+ onCancel,
+ error,
+}: {
+ onChange: (value: string) => void;
+ onCancel: () => void;
+ error?: string;
+}) => {
+ return (
+
+ {
+ onChange(event.target.value);
+ }}
+ css={{
+ width: '100%',
+ }}
+ />
+
+ Cancel
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+};
+
+export function Field({
+ autoFocus,
+ field,
+ value,
+ forceValidation,
+ onChange,
+}: FieldProps) {
+ const inputRef = useRef(null);
+
+ const errorMessage = createErrorMessage(value, forceValidation);
+
+ const onUploadChange = ({
+ currentTarget: { validity, files },
+ }: React.SyntheticEvent) => {
+ const file = files?.[0];
+ if (!file) return; // bail if the user cancels from the file browser
+ onChange?.({
+ kind: 'upload',
+ data: { file, validity },
+ previous: value,
+ });
+ };
+
+ // Generate a random input key when the value changes, to ensure the file input is unmounted and
+ // remounted (this is the only way to reset its value and ensure onChange will fire again if
+ // the user selects the same file again)
+ const inputKey = useMemo(() => Math.random(), [value]);
+
+ return (
+
+ {field.label}
+ {value.kind === 'ref' ? (
+ {
+ onChange?.({
+ kind: 'ref',
+ data: { ref },
+ previous: value.previous,
+ });
+ }}
+ error={forceValidation && errorMessage ? errorMessage : undefined}
+ onCancel={() => {
+ onChange?.(value.previous);
+ }}
+ />
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ImgView({
+ errorMessage,
+ value,
+ onChange,
+ field,
+ inputRef,
+}: {
+ errorMessage?: string;
+ value: Exclude;
+ onChange?: (value: ImageValue) => void;
+ field: ReturnType;
+ inputRef: RefObject;
+}) {
+ const { addToast } = useToasts();
+
+ const imagePathFromUpload = useObjectURL(
+ errorMessage === undefined && value.kind === 'upload' ? value.data.file : undefined
+ );
+ const onSuccess = () => {
+ addToast({ title: 'Copied image ref to clipboard', tone: 'positive' });
+ };
+ const onFailure = () => {
+ addToast({ title: 'Failed to copy image ref to clipboard', tone: 'negative' });
+ };
+
+ const copyRef = () => {
+ if (value.kind !== 'from-server') {
+ return;
+ }
+
+ if (navigator) {
+ // use the new navigator.clipboard API if it exists
+ navigator.clipboard.writeText(value?.data.ref).then(onSuccess, onFailure);
+ return;
+ } else {
+ // Fallback to a library that leverages document.execCommand
+ // for browser versions that dont' support the navigator object.
+ // As document.execCommand
+ try {
+ copy(value?.data.ref);
+ } catch (e) {
+ addToast({ title: 'Faild to oopy to clipboard', tone: 'negative' });
+ }
+
+ return;
+ }
+ };
+ return value.kind === 'from-server' || value.kind === 'upload' ? (
+
+ {errorMessage === undefined ? (
+ value.kind === 'from-server' ? (
+
+
+
+ ) : (
+
+
+
+ )
+ ) : null}
+ {onChange && (
+
+ {value.kind === 'from-server' && (
+
+
+
+
+ {`${value.data.id}.${value.data.extension}`}
+
+
+
+ Copy
+
+
+ {`${value.data.width} x ${value.data.height} (${bytes(
+ value.data.filesize
+ )})`}
+
+ )}
+
+ {
+ inputRef.current?.click();
+ }}
+ >
+ Change
+
+ {value.kind !== 'upload' ? (
+ {
+ onChange({
+ kind: 'ref',
+ data: { ref: '' },
+ previous: value,
+ });
+ }}
+ >
+ Paste
+
+ ) : null}
+ {value.kind === 'from-server' && (
+ {
+ onChange({ kind: 'remove', previous: value });
+ }}
+ >
+ Remove
+
+ )}
+ {value.kind === 'upload' && (
+ {
+ onChange(value.previous);
+ }}
+ >
+ Cancel
+
+ )}
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : (
+ value.kind === 'upload' && (
+
+ Save to upload this image
+
+ )
+ )}
+
+
+ )}
+
+ ) : (
+
+
+ {
+ inputRef.current?.click();
+ }}
+ tone="positive"
+ >
+ Upload Image
+
+ {
+ onChange?.({
+ kind: 'ref',
+ data: {
+ ref: '',
+ },
+ previous: value,
+ });
+ }}
+ >
+ Paste Ref
+
+ {value.kind === 'remove' && value.previous && (
+ {
+ if (value.previous !== undefined) {
+ onChange?.(value?.previous);
+ }
+ }}
+ >
+ Undo removal
+
+ )}
+ {value.kind === 'remove' &&
+ // NOTE -- UX decision is to not display this, I think it would only be relevant
+ // for deleting uploaded images (and we don't support that yet)
+ //
+ // Save to remove this image
+ //
+ null}
+
+
+ );
+}
+
+export function validateRef({ ref }: { ref: string }) {
+ if (!parseImageRef(ref)) {
+ return 'Invalid ref';
+ }
+}
+
+function createErrorMessage(value: ImageValue, forceValidation?: boolean) {
+ if (value.kind === 'upload') {
+ return validateImage(value.data);
+ } else if (value.kind === 'ref') {
+ return forceValidation ? validateRef(value.data) : undefined;
+ }
+}
+
+export function validateImage({
+ file,
+ validity,
+}: {
+ file: File;
+ validity: ValidityState;
+}): string | undefined {
+ if (!validity.valid) {
+ return 'Something went wrong, please reload and try again.';
+ }
+ // check if the file is actually an image
+ if (!file.type.includes('image')) {
+ return 'Only image files are allowed. Please try again.';
+ }
+}
+
+// ==============================
+// Styled Components
+// ==============================
+
+const ImageWrapper = ({ children }: { children: ReactNode }) => {
+ const theme = useTheme();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages-next/fields/src/types/image/views/index.tsx b/packages-next/fields/src/types/image/views/index.tsx
new file mode 100644
index 00000000000..7350b991c65
--- /dev/null
+++ b/packages-next/fields/src/types/image/views/index.tsx
@@ -0,0 +1,127 @@
+/* @jsx jsx */
+
+import { jsx } from '@keystone-ui/core';
+import {
+ CardValueComponent,
+ CellComponent,
+ FieldController,
+ FieldControllerConfig,
+} from '@keystone-next/types';
+import { FieldContainer, FieldLabel } from '@keystone-ui/fields';
+import { validateImage, validateRef } from './Field';
+
+export { Field } from './Field';
+
+export const Cell: CellComponent = ({ item, field }) => {
+ const data = item[field.path];
+ if (!data) return null;
+ return (
+
+
+
+ );
+};
+
+export const CardValue: CardValueComponent = ({ item, field }) => {
+ const data = item[field.path];
+ return (
+
+ {field.label}
+ {data && }
+
+ );
+};
+
+type ImageData = {
+ src: string;
+ ref: string;
+ height: number;
+ width: number;
+ filesize: number;
+ extension: string;
+ id: string;
+};
+
+export type ImageValue =
+ | { kind: 'empty' }
+ | {
+ kind: 'ref';
+ data: {
+ ref: string;
+ };
+ previous: ImageValue;
+ }
+ | {
+ kind: 'from-server';
+ data: ImageData;
+ }
+ | {
+ kind: 'upload';
+ data: {
+ file: File;
+ validity: ValidityState;
+ };
+ previous: ImageValue;
+ }
+ | { kind: 'remove'; previous?: Exclude };
+
+type ImageController = FieldController;
+
+export const controller = (config: FieldControllerConfig): ImageController => {
+ return {
+ path: config.path,
+ label: config.label,
+ graphqlSelection: `${config.path} {
+ src
+ id
+ ref
+ extension
+ width
+ height
+ filesize
+ }`,
+ defaultValue: { kind: 'empty' },
+ deserialize(item) {
+ const value = item[config.path];
+ if (!value) return { kind: 'empty' };
+ return {
+ kind: 'from-server',
+ data: {
+ src: value.src,
+ id: value.id,
+ extension: value.extension,
+ ref: value.ref,
+ width: value.width,
+ height: value.height,
+ filesize: value.filesize,
+ },
+ };
+ },
+ validate(value): boolean {
+ if (value.kind === 'ref') {
+ return validateRef(value.data) === undefined;
+ }
+ return value.kind !== 'upload' || validateImage(value.data) === undefined;
+ },
+ serialize(value) {
+ if (value.kind === 'upload') {
+ return { [config.path]: { upload: value.data.file } };
+ }
+ if (value.kind === 'ref') {
+ return { [config.path]: { ref: value.data.ref } };
+ }
+ if (value.kind === 'remove') {
+ return { [config.path]: null };
+ }
+ return {};
+ },
+ };
+};
diff --git a/packages-next/fields/types/image/views/package.json b/packages-next/fields/types/image/views/package.json
new file mode 100644
index 00000000000..0a2b32cebb5
--- /dev/null
+++ b/packages-next/fields/types/image/views/package.json
@@ -0,0 +1,4 @@
+{
+ "main": "dist/fields.cjs.js",
+ "module": "dist/fields.esm.js"
+}
diff --git a/packages-next/keystone/package.json b/packages-next/keystone/package.json
index 9546365c560..4995dabb66a 100644
--- a/packages-next/keystone/package.json
+++ b/packages-next/keystone/package.json
@@ -50,6 +50,7 @@
"@types/prompts": "^2.0.10",
"@types/source-map-support": "^0.5.3",
"@types/uid-safe": "^2.1.2",
+ "@types/uuid": "^8.3.0",
"apollo-errors": "^1.9.0",
"apollo-server-express": "^2.22.2",
"apollo-server-micro": "^2.22.2",
@@ -64,6 +65,8 @@
"graphql": "^15.5.0",
"graphql-type-json": "^0.3.2",
"graphql-upload": "^11.0.0",
+ "image-size": "^0.9.7",
+ "image-type": "^4.1.0",
"meow": "^9.0.0",
"next": "^10.0.9",
"object-hash": "^2.1.1",
@@ -78,7 +81,8 @@
"source-map-support": "^0.5.19",
"stack-utils": "^2.0.3",
"typescript": "^4.2.3",
- "uid-safe": "^2.1.5"
+ "uid-safe": "^2.1.5",
+ "uuid": "^8.3.2"
},
"devDependencies": {
"fast-glob": "^3.2.5",
diff --git a/packages-next/keystone/src/lib/createContext.ts b/packages-next/keystone/src/lib/createContext.ts
index 8f21c52570b..a9fba5a49b4 100644
--- a/packages-next/keystone/src/lib/createContext.ts
+++ b/packages-next/keystone/src/lib/createContext.ts
@@ -5,6 +5,7 @@ import type {
KeystoneContext,
KeystoneGraphQLAPI,
BaseKeystone,
+ ImagesContext,
} from '@keystone-next/types';
import { itemAPIForList, getArgsFactory } from './itemAPI';
@@ -14,10 +15,12 @@ export function makeCreateContext({
graphQLSchema,
internalSchema,
keystone,
+ images,
}: {
graphQLSchema: GraphQLSchema;
internalSchema: GraphQLSchema;
keystone: BaseKeystone;
+ images: ImagesContext | undefined;
}) {
// We precompute these helpers here rather than every time createContext is called
// because they require parsing the entire schema, which is potentially expensive.
@@ -81,6 +84,7 @@ export function makeCreateContext({
// Note: This field lets us use the server-side-graphql-client library.
// We may want to remove it once the updated itemAPI w/ query is available.
gqlNames: (listKey: string) => keystone.lists[listKey].gqlNames,
+ images,
};
const getArgsByList = schemaName === 'public' ? publicGetArgsByList : internalGetArgsByList;
for (const [listKey, list] of Object.entries(keystone.lists)) {
diff --git a/packages-next/keystone/src/lib/createImagesContext.ts b/packages-next/keystone/src/lib/createImagesContext.ts
new file mode 100644
index 00000000000..33592c480c0
--- /dev/null
+++ b/packages-next/keystone/src/lib/createImagesContext.ts
@@ -0,0 +1,93 @@
+import path from 'path';
+import {
+ ImagesConfig as KeystoneImagesConfig,
+ ImagesContext,
+ ImageExtension,
+} from '@keystone-next/types';
+import { v4 as uuid } from 'uuid';
+import fs from 'fs-extra';
+import fromBuffer from 'image-type';
+import imageSize from 'image-size';
+
+import { parseImageRef } from '@keystone-next/utils-legacy';
+
+const DEFAULT_BASE_URL = '/images';
+const DEFAULT_STORAGE_PATH = './public/images';
+
+const getImageMetadataFromBuffer = async (buffer: Buffer) => {
+ const filesize = buffer.length;
+ const fileType = fromBuffer(buffer);
+ if (!fileType) {
+ throw new Error('File type not found');
+ }
+
+ if (
+ fileType.ext !== 'jpg' &&
+ fileType.ext !== 'png' &&
+ fileType.ext !== 'webp' &&
+ fileType.ext !== 'gif'
+ ) {
+ throw new Error(`${fileType.ext} is not a supported image type`);
+ }
+
+ const extension: ImageExtension = fileType.ext;
+
+ const { height, width } = imageSize(buffer);
+
+ if (width === undefined || height === undefined) {
+ throw new Error('Height and width could not be found for image');
+ }
+ return { width, height, filesize, extension };
+};
+
+export function createImagesContext(config?: KeystoneImagesConfig): ImagesContext | undefined {
+ if (!config) {
+ return;
+ }
+
+ const { baseUrl = DEFAULT_BASE_URL, storagePath = DEFAULT_STORAGE_PATH } = config.local || {};
+
+ fs.mkdirSync(storagePath, { recursive: true });
+
+ return {
+ getSrc: (mode, id, extension) => {
+ const filename = `${id}.${extension}`;
+ return `${baseUrl}/${filename}`;
+ },
+ getDataFromRef: async ref => {
+ const imageRef = parseImageRef(ref);
+ if (!imageRef) {
+ throw new Error('Invalid image reference');
+ }
+ const buffer = await fs.readFile(
+ path.join(storagePath, `${imageRef.id}.${imageRef.extension}`)
+ );
+ const metadata = await getImageMetadataFromBuffer(buffer);
+
+ return {
+ ...imageRef,
+ ...metadata,
+ };
+ },
+ getDataFromStream: async stream => {
+ const { upload: mode } = config;
+ const id = uuid();
+ const chunks = [];
+
+ for await (let chunk of stream) {
+ chunks.push(chunk);
+ }
+
+ const buffer = Buffer.concat(chunks);
+ const metadata = await getImageMetadataFromBuffer(buffer);
+
+ await fs.writeFile(path.join(storagePath, `${id}.${metadata.extension}`), buffer);
+
+ return {
+ mode,
+ id,
+ ...metadata,
+ };
+ },
+ };
+}
diff --git a/packages-next/keystone/src/lib/createSystem.ts b/packages-next/keystone/src/lib/createSystem.ts
index dba9670d4c4..6b9a3244012 100644
--- a/packages-next/keystone/src/lib/createSystem.ts
+++ b/packages-next/keystone/src/lib/createSystem.ts
@@ -3,6 +3,7 @@ import type { KeystoneConfig } from '@keystone-next/types';
import { createGraphQLSchema } from './createGraphQLSchema';
import { makeCreateContext } from './createContext';
import { createKeystone } from './createKeystone';
+import { createImagesContext } from './createImagesContext';
export function createSystem(config: KeystoneConfig, prismaClient?: any) {
const keystone = createKeystone(config, prismaClient);
@@ -11,7 +12,12 @@ export function createSystem(config: KeystoneConfig, prismaClient?: any) {
const internalSchema = createGraphQLSchema(config, keystone, 'internal');
- const createContext = makeCreateContext({ keystone, graphQLSchema, internalSchema });
+ const createContext = makeCreateContext({
+ keystone,
+ graphQLSchema,
+ internalSchema,
+ images: createImagesContext(config.images),
+ });
return { keystone, graphQLSchema, createContext };
}
diff --git a/packages-next/types/src/config/index.ts b/packages-next/types/src/config/index.ts
index d9e01130531..ae2f0f2f716 100644
--- a/packages-next/types/src/config/index.ts
+++ b/packages-next/types/src/config/index.ts
@@ -3,7 +3,7 @@ import { CorsOptions } from 'cors';
import type { GraphQLSchema } from 'graphql';
import type { Config } from 'apollo-server-express';
-import type { KeystoneContext } from '..';
+import type { ImageMode, KeystoneContext } from '..';
import { CreateContext } from '../core';
import type { BaseKeystone } from '../base';
@@ -39,6 +39,23 @@ export type KeystoneConfig = {
/** Creates a file at `node_modules/.keystone/next/graphql-api` with `default` and `config` exports that can be re-exported in a Next API route */
generateNextGraphqlAPI?: boolean;
};
+ images?: ImagesConfig;
+};
+
+export type ImagesConfig = {
+ upload: ImageMode;
+ local?: {
+ /**
+ * The path local images are uploaded to.
+ * @default 'public/images'
+ */
+ storagePath?: string;
+ /**
+ * The base of the URL local images will be served from, outside of keystone.
+ * @default '/images'
+ */
+ baseUrl?: string;
+ };
};
// config.lists
diff --git a/packages-next/types/src/context.ts b/packages-next/types/src/context.ts
index 0d534ea28f8..e47207e065e 100644
--- a/packages-next/types/src/context.ts
+++ b/packages-next/types/src/context.ts
@@ -1,4 +1,5 @@
import { IncomingMessage } from 'http';
+import { Readable } from 'stream';
import { GraphQLSchema, ExecutionResult, DocumentNode } from 'graphql';
import { BaseKeystone } from './base';
import type { BaseGeneratedListTypes } from './utils';
@@ -17,6 +18,7 @@ export type KeystoneContext = {
gqlNames: (listKey: string) => Record; // TODO: actual keys
/** @deprecated */
keystone: BaseKeystone;
+ images: ImagesContext | undefined;
} & AccessControlContext &
Partial> &
DatabaseAPIs;
@@ -111,3 +113,22 @@ export type SessionContext = {
export type DatabaseAPIs = {
prisma?: any;
};
+
+export type ImageMode = 'local';
+
+export type ImageExtension = 'jpg' | 'png' | 'webp' | 'gif';
+
+export type ImageData = {
+ mode: ImageMode;
+ id: string;
+ extension: ImageExtension;
+ filesize: number;
+ width: number;
+ height: number;
+};
+
+export type ImagesContext = {
+ getSrc: (mode: ImageMode, id: string, extension: ImageExtension) => string;
+ getDataFromRef: (ref: string) => Promise;
+ getDataFromStream: (stream: Readable) => Promise;
+};
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 4bd214fcdc6..46b01f6dfcc 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@babel/runtime": "^7.13.10",
+ "@keystone-next/types": "^16.0.0",
"p-lazy": "^3.1.0",
"p-reflect": "^2.1.0",
"semver": "^7.3.5"
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index f22ca9d4d09..8f76813c904 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -1,6 +1,7 @@
import pLazy from 'p-lazy';
import pReflect from 'p-reflect';
import semver from 'semver';
+import { ImageMode, ImageExtension } from '@keystone-next/types';
export const noop = (x: T): T => x;
export const identity = noop;
@@ -256,3 +257,25 @@ export const humanize = (str: string) => {
.map(upcase)
.join(' ');
};
+
+const REFREGEX = /^(local):([^:\n]+)\.(gif|jpg|png|webp)$/;
+
+export const getImageRef = (mode: ImageMode, id: string, extension: ImageExtension) =>
+ `${mode}:${id}.${extension}`;
+
+export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'png', 'webp', 'gif'];
+
+export const parseImageRef = (
+ ref: string
+): { mode: ImageMode; id: string; extension: ImageExtension } | undefined => {
+ const match = ref.match(REFREGEX);
+ if (match) {
+ const [, mode, id, ext] = match;
+ return {
+ mode: mode as ImageMode,
+ id,
+ extension: ext as ImageExtension,
+ };
+ }
+ return undefined;
+};
diff --git a/yarn.lock b/yarn.lock
index 2152429f9e1..10a5adb06bc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2523,6 +2523,11 @@
"@types/connect" "*"
"@types/node" "*"
+"@types/bytes@^3.1.0":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/@types/bytes/-/bytes-3.1.0.tgz#835a3e4aea3b4d7604aca216a78de372bff3ecc3"
+ integrity sha512-5YG1AiIC8HPPXRvYAIa7ehK3YMAwd0DWiPCtpuL9sgKceWLyWsVtLRA+lT4NkoanDNF9slwQ66lPizWDpgRlWA==
+
"@types/classnames@^2.2.11":
version "2.2.11"
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf"
@@ -3013,6 +3018,11 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
+"@types/uuid@^8.3.0":
+ version "8.3.0"
+ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
+ integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
+
"@types/vfile-message@*":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/vfile-message/-/vfile-message-2.0.0.tgz#690e46af0fdfc1f9faae00cd049cc888957927d5"
@@ -4340,7 +4350,7 @@ bytes@3.0.0:
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
-bytes@3.1.0, bytes@^3.0.0:
+bytes@3.1.0, bytes@^3.0.0, bytes@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
@@ -6514,6 +6524,11 @@ file-loader@^6.0.0:
loader-utils "^2.0.0"
schema-utils "^3.0.0"
+file-type@^10.10.0:
+ version "10.11.0"
+ resolved "https://registry.yarnpkg.com/file-type/-/file-type-10.11.0.tgz#2961d09e4675b9fb9a3ee6b69e9cd23f43fd1890"
+ integrity sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==
+
file-uri-to-path@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@@ -7507,6 +7522,20 @@ ignore@^5.1.4:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
+image-size@^0.9.7:
+ version "0.9.7"
+ resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.9.7.tgz#43b4ead4b1310d5ae310a559d52935a347e47c09"
+ integrity sha512-KRVgLNZkr00YGN0qn9MlIrmlxbRhsCcEb1Byq3WKGnIV4M48iD185cprRtaoK4t5iC+ym2Q5qlArxZ/V1yzDgA==
+ dependencies:
+ queue "6.0.2"
+
+image-type@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/image-type/-/image-type-4.1.0.tgz#72a88d64ff5021371ed67b9a466442100be57cd1"
+ integrity sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==
+ dependencies:
+ file-type "^10.10.0"
+
immer@^7.0.0:
version "7.0.15"
resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.15.tgz#dc3bc6db87401659d2e737c67a21b227c484a4ad"
@@ -10935,6 +10964,13 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+queue@6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65"
+ integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==
+ dependencies:
+ inherits "~2.0.3"
+
quick-lru@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8"
@@ -13653,7 +13689,7 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
-uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0:
+uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==