diff --git a/gatsby-node.js b/gatsby-node.js index b4b6da80..4c8980a5 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -59,6 +59,9 @@ exports.createSchemaCustomization = ({ actions, schema }) => { image: File @fileByRelativePath caption: String } + type ImagesAndVideos { + images: [ImgWithCaption] + } type Diagram { title: String images: [ImgWithCaption] @@ -97,6 +100,7 @@ exports.createSchemaCustomization = ({ actions, schema }) => { parental_line: MarkdownRemark @link(by: "frontmatter.cell_line_id") funding_text: String @md footer_text: String @md + images_and_videos: ImagesAndVideos genomic_characterization: MarkdownRemarkFrontmatterGenomic_characterization stem_cell_characteristics: StemCellCharacteristics catalogs: [NavBarDropdownItem] @@ -108,6 +112,28 @@ exports.createSchemaCustomization = ({ actions, schema }) => { createTypes(typeDefs); }; +// Expose the raw image string as `image_url` for Cloudinary URL support. +// The existing `image: File @fileByRelativePath` returns null for URLs, +// so we need this field to pass through Cloudinary URLs to the frontend. +exports.createResolvers = ({ createResolvers }) => { + const imageUrlResolver = { + image_url: { + type: "String", + resolve: (source) => { + const img = source.image; + if (typeof img === "string") return img; + return null; + }, + }, + }; + createResolvers({ + ImgWithCaption: imageUrlResolver, + RnaSeqRow: imageUrlResolver, + MarkdownRemarkFrontmatterEditing_designDiagramsImages: imageUrlResolver, + Diagram: imageUrlResolver, + }); +}; + exports.createPages = ({ actions, graphql }) => { const { createPage } = actions; diff --git a/src/cms/cloudinaryConfig.ts b/src/cms/cloudinaryConfig.ts new file mode 100644 index 00000000..c440d2cc --- /dev/null +++ b/src/cms/cloudinaryConfig.ts @@ -0,0 +1,6 @@ +// Cloudinary Upload Widget configuration +// These are public, client-side values (not secrets). +// The upload preset restricts what operations are allowed. +export const CLOUDINARY_CLOUD_NAME = "dkg6lnogl"; +export const CLOUDINARY_UPLOAD_PRESET = "allen-cell"; +export const CLOUDINARY_API_KEY = "989839737788897"; diff --git a/src/cms/cms.tsx b/src/cms/cms.tsx index dd9b8922..34aa5dcb 100644 --- a/src/cms/cms.tsx +++ b/src/cms/cms.tsx @@ -1,14 +1,27 @@ import CMS from "decap-cms-app"; import cloudinary from "decap-cms-media-library-cloudinary"; import uploadcare from "decap-cms-media-library-uploadcare"; +import React from "react"; import CellLinePreview from "./preview-templates/CellLinePreview"; import DiseaseCatalogPreview from "./preview-templates/DiseaseCatalogPreview"; import DiseaseCellLinePreview from "./preview-templates/DiseaseCellLinePreview"; import GeneNamePreview from "./preview-templates/GeneNamePreview"; +import { + CloudinaryImagePreview, + CloudinaryImageWidget, +} from "./widgets/CloudinaryImageWidget"; CMS.registerMediaLibrary(uploadcare); CMS.registerMediaLibrary(cloudinary); + +// Decap CMS's registerWidget type doesn't include `entry` in control props, +// but it passes it at runtime (Immutable.js Map). Cast required here. +CMS.registerWidget( + "cloudinary-image", + CloudinaryImageWidget as unknown as React.FC, + CloudinaryImagePreview, +); CMS.registerPreviewStyle( "https://cdnjs.cloudflare.com/ajax/libs/antd/4.4.3/antd.min.css", ); diff --git a/src/cms/widgets/CloudinaryImageWidget.tsx b/src/cms/widgets/CloudinaryImageWidget.tsx new file mode 100644 index 00000000..628c1a47 --- /dev/null +++ b/src/cms/widgets/CloudinaryImageWidget.tsx @@ -0,0 +1,314 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import { + CLOUDINARY_API_KEY, + CLOUDINARY_CLOUD_NAME, + CLOUDINARY_UPLOAD_PRESET, +} from "../cloudinaryConfig"; + +// Decap CMS passes Immutable.js Maps — type the minimal surface we use +interface ImmutableMap { + get: (key: string) => unknown; +} + +interface AssetProxy { + toString: () => string; +} + +interface CloudinaryWidgetProps { + value?: string; + onChange: (value: string) => void; + entry: ImmutableMap; + field: ImmutableMap; + getAsset: (path: string, field: ImmutableMap) => AssetProxy; +} + +type CloudinaryWidgetInstance = { + destroy: () => void; + open: () => void; +}; + +type CloudinaryUploadResult = { + event: string; + info: { secure_url: string }; +}; + +type CloudinaryWindow = Window & { + cloudinary?: { + createUploadWidget: ( + config: object, + callback: ( + error: Error | null, + result: CloudinaryUploadResult, + ) => void, + ) => CloudinaryWidgetInstance; + }; +}; + +// Load the Cloudinary Upload Widget script once +let scriptLoaded = false; +function loadCloudinaryScript(): Promise { + if (scriptLoaded) return Promise.resolve(); + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = + "https://upload-widget.cloudinary.com/latest/global/all.js"; + script.onload = () => { + scriptLoaded = true; + resolve(); + }; + script.onerror = reject; + document.head.appendChild(script); + }); +} + +/** + * Derives the Cloudinary upload folder from the current CMS entry. + * Maps each collection's templateKey to a folder structure: + * cell-line → cell-lines/AICS-{id}-{clone} + * disease-cell-line → disease-cell-lines/AICS-{id} + * normal-catalog → pages/normal-catalog + * disease-catalog → pages/disease-catalog + * (unknown) → uploads + */ +function getFolderFromEntry(entry: ImmutableMap): string { + const data = entry?.get("data") as ImmutableMap | undefined; + if (!data) return "uploads"; + + const templateKey = data.get("templateKey") as string | undefined; + const cellLineId = data.get("cell_line_id"); + const cloneNumber = data.get("clone_number"); + + switch (templateKey) { + case "cell-line": + if (cellLineId != null && cloneNumber != null) { + return `cell-lines/AICS-${cellLineId}-${cloneNumber}`; + } + return "cell-lines"; + case "disease-cell-line": + if (cellLineId != null) { + return `disease-cell-lines/AICS-${cellLineId}`; + } + return "disease-cell-lines"; + case "normal-catalog": + return "pages/normal-catalog"; + case "disease-catalog": + return "pages/disease-catalog"; + default: + return "uploads"; + } +} + +// Decap CMS passes `entry` to widget controls at runtime, +// but the TypeScript types don't declare it (Immutable.js Map). +// Cast needed at registration site (cms.tsx) for the same reason. +const CloudinaryImageWidget: React.FC = ({ + entry, + field, + getAsset, + onChange, + value, +}) => { + const widgetRef = useRef(null); + + const openUploader = useCallback(async () => { + await loadCloudinaryScript(); + + const folder = getFolderFromEntry(entry); + const cloudinary = (window as CloudinaryWindow).cloudinary; + + if (!cloudinary) { + console.error("Cloudinary upload widget not loaded"); + return; + } + + // Close any existing widget + if (widgetRef.current) { + widgetRef.current.destroy(); + } + + widgetRef.current = cloudinary.createUploadWidget( + { + cloudName: CLOUDINARY_CLOUD_NAME, + uploadPreset: CLOUDINARY_UPLOAD_PRESET, + apiKey: CLOUDINARY_API_KEY, + folder: folder, + sources: ["local", "url", "camera"], + multiple: false, + resourceType: "image", + clientAllowedFormats: [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "svg", + "tiff", + ], + showPoweredBy: false, + styles: { + palette: { + window: "#FFFFFF", + windowBorder: "#607E96", + tabIcon: "#607E96", + menuIcons: "#5A616A", + textDark: "#000000", + textLight: "#FFFFFF", + link: "#607E96", + action: "#339933", + inactiveTabIcon: "#B3B3B3", + error: "#F44235", + inProgress: "#607E96", + complete: "#339933", + sourceBg: "#F4F4F5", + }, + }, + }, + (error: Error | null, result: CloudinaryUploadResult) => { + if (error) { + console.error("Cloudinary upload error:", error); + return; + } + if (result.event === "success") { + const url = result.info.secure_url; + onChange(url); + } + }, + ); + + widgetRef.current.open(); + }, [entry, onChange]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (widgetRef.current) { + widgetRef.current.destroy(); + } + }; + }, []); + + const folder = getFolderFromEntry(entry); + const hasImage = value && value.length > 0; + const isUrl = hasImage && value.startsWith("http"); + + const [localSrc, setLocalSrc] = useState(null); + useEffect(() => { + if (!hasImage || isUrl) { + setLocalSrc(null); + return; + } + // getAsset returns an AssetProxy; its path may resolve async + const asset = getAsset(value, field); + const src = asset.toString(); + if (src && !src.endsWith("undefined")) { + setLocalSrc(src); + } + // Poll briefly for the blob to be populated by the CMS proxy + const interval = setInterval(() => { + const resolved = getAsset(value, field).toString(); + if (resolved && resolved !== src) { + setLocalSrc(resolved); + clearInterval(interval); + } + }, 500); + return () => clearInterval(interval); + }, [value, field, getAsset, hasImage, isUrl]); + + const previewSrc = isUrl ? value : localSrc; + + return ( +
+ {hasImage && previewSrc && ( +
+ Uploaded +
+ {value} +
+
+ )} + +
+ + + {hasImage && ( + + )} +
+ +
+ Uploads to: {folder} +
+
+ ); +}; + +// Preview component for the CMS preview pane +const CloudinaryImagePreview: React.FC<{ value?: string }> = ({ value }) => { + if (!value) return null; + return ( + Preview + ); +}; + +export { CloudinaryImageWidget, CloudinaryImagePreview }; diff --git a/src/component-queries/DiseaseCellLines.tsx b/src/component-queries/DiseaseCellLines.tsx index 7c04b320..01b5df0e 100644 --- a/src/component-queries/DiseaseCellLines.tsx +++ b/src/component-queries/DiseaseCellLines.tsx @@ -78,6 +78,7 @@ export default function DiseaseCellLineQuery(props: { ) } } + image_url caption } } diff --git a/src/component-queries/NormalCellLines.tsx b/src/component-queries/NormalCellLines.tsx index ae2cf510..237538a7 100644 --- a/src/component-queries/NormalCellLines.tsx +++ b/src/component-queries/NormalCellLines.tsx @@ -55,6 +55,7 @@ export default function NormalCellLines() { ) } } + image_url caption } } diff --git a/src/component-queries/types.ts b/src/component-queries/types.ts index c69da063..3b88553d 100644 --- a/src/component-queries/types.ts +++ b/src/component-queries/types.ts @@ -1,5 +1,8 @@ import { IGatsbyImageData } from "gatsby-plugin-image"; +// Image can be either GatsbyImage(local) or a url string(cloud) +export type ImageSource = IGatsbyImageData | string; + // this is the image that comes from the CMS // without any data processing // it's just the user entered data @@ -25,12 +28,13 @@ export interface RawImageData { childImageSharp: { gatsbyImageData: IGatsbyImageData; }; - }; + } | null; + image_url?: string | null; caption: string; } export interface UnpackedImageData { - image: IGatsbyImageData; + image: ImageSource; caption: string; } @@ -266,7 +270,7 @@ export interface UnpackedCellLineMainInfo { certificateOfAnalysis: string; healthCertificate: string; orderLink: string; - thumbnailImage?: IGatsbyImageData | null; + thumbnailImage?: ImageSource | null; imagesAndVideos?: MediaFrontmatter; } diff --git a/src/components/CellLineTable/SharedColumns.tsx b/src/components/CellLineTable/SharedColumns.tsx index 3e7fce96..6fcfcc31 100644 --- a/src/components/CellLineTable/SharedColumns.tsx +++ b/src/components/CellLineTable/SharedColumns.tsx @@ -2,14 +2,16 @@ import Icon from "@ant-design/icons"; import { Flex, Tooltip } from "antd"; import classNames from "classnames"; import { Link } from "gatsby"; -import { GatsbyImage, getImage } from "gatsby-plugin-image"; +import { getImage } from "gatsby-plugin-image"; import React from "react"; import { CellLineStatus } from "../../component-queries/types"; import CertificateIcon from "../../img/cert-icon.svg"; import { WHITE } from "../../style/theme"; import { formatCellLineId, openLinkInNewTab } from "../../utils"; +import { isExternalUrl } from "../../utils/mediaUtils"; import TubeIcon from "../Icons/TubeIcon"; +import ImageRenderer from "../shared/ImageRenderer"; import { UnpackedCellLine, mdBreakpoint } from "./types"; const { @@ -34,15 +36,23 @@ export const cellLineIdColumn = { const cellLine = (

{formatCellLineId(cellLineId)}

); - const thumbnailImage = getImage(record.thumbnailImage || null); + const thumbnailSrc = record.thumbnailImage; + const hasImage = + thumbnailSrc && + (isExternalUrl(thumbnailSrc) || getImage(thumbnailSrc)); - const content = thumbnailImage ? ( + const content = hasImage ? ( <>
{cellLine}
-
diff --git a/src/components/ImagesAndVideo/ImagePreviewGroup.tsx b/src/components/ImagesAndVideo/ImagePreviewGroup.tsx index 0a4b4331..d310ca3a 100644 --- a/src/components/ImagesAndVideo/ImagePreviewGroup.tsx +++ b/src/components/ImagesAndVideo/ImagePreviewGroup.tsx @@ -4,6 +4,7 @@ import { getSrc } from "gatsby-plugin-image"; import React from "react"; import { ImageOrVideo, UnpackedImageData } from "../../component-queries/types"; +import { isExternalUrl } from "../../utils/mediaUtils"; interface ImagePreviewGroupProps { mediaItems: ImageOrVideo[]; @@ -30,10 +31,11 @@ export const PreviewGroup = ({ setSelectedMedia, }: ImagePreviewGroupProps) => { const allPreviewImages = imageItems.map((item, i) => { + const src = isExternalUrl(item.image) ? item.image : getSrc(item.image); return ( diff --git a/src/components/ImagesAndVideo/MediaCard.tsx b/src/components/ImagesAndVideo/MediaCard.tsx index 8fb29744..46dbb25f 100644 --- a/src/components/ImagesAndVideo/MediaCard.tsx +++ b/src/components/ImagesAndVideo/MediaCard.tsx @@ -1,9 +1,9 @@ import { Card, Flex } from "antd"; -import { GatsbyImage } from "gatsby-plugin-image"; import React from "react"; import { ImageOrVideo } from "../../component-queries/types"; import { isImage } from "../../utils/mediaUtils"; +import ImageRenderer from "../shared/ImageRenderer"; const { caption, @@ -36,12 +36,12 @@ const renderMediaContent = ( if (isImageItem) { return ( - ); diff --git a/src/components/ParentalLineModal.tsx b/src/components/ParentalLineModal.tsx index 847ae744..54b548d5 100644 --- a/src/components/ParentalLineModal.tsx +++ b/src/components/ParentalLineModal.tsx @@ -1,12 +1,12 @@ import Icon, { InfoCircleOutlined } from "@ant-design/icons"; import { Descriptions, Divider, Flex, Modal } from "antd"; -import { GatsbyImage, IGatsbyImageData, getImage } from "gatsby-plugin-image"; import React, { useState } from "react"; -import { UnpackedGene } from "../component-queries/types"; +import { ImageSource, UnpackedGene } from "../component-queries/types"; import LinkOutIcon from "../img/external-link.svg"; import { formatCellLineSlug } from "../utils"; import { DarkBlueHoverButton } from "./shared/Buttons"; +import ImageRenderer from "./shared/ImageRenderer"; const { actionButton, @@ -20,7 +20,7 @@ const { } = require("../style/modal.module.css"); interface ParentalLineModalProps { - image?: IGatsbyImageData | null; + image?: ImageSource | null; formattedId: string; cellLineId: number; cloneNumber: number; @@ -43,7 +43,7 @@ const ParentalLineModal = (props: ParentalLineModalProps) => { props.suppressRowClickRef.current = false; setIsModalOpen(false); }; - const image = getImage(props.image ?? null); + const rawImage = props.image; const headerElement = (
Parental Line
@@ -120,10 +120,15 @@ const ParentalLineModal = (props: ParentalLineModalProps) => { >
- {image && ( - )}
diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx index 92703847..5bafe2cd 100644 --- a/src/components/Thumbnail.tsx +++ b/src/components/Thumbnail.tsx @@ -1,7 +1,9 @@ import classNames from "classnames"; -import { GatsbyImage, IGatsbyImageData } from "gatsby-plugin-image"; import React from "react"; +import { ImageSource } from "../component-queries/types"; +import ImageRenderer from "./shared/ImageRenderer"; + const { selectedThumbnail, thumbnail, @@ -10,7 +12,7 @@ const { } = require("../style/thumbnail.module.css"); interface ThumbnailProps { - image?: IGatsbyImageData; + image?: ImageSource; videoId?: string; isSelected: boolean; onClick: () => void; @@ -38,7 +40,15 @@ const Thumbnail: React.FC = ({ role="button" > {type === "image" && image ? ( - + ) : ( = ({ if (!image) { return null; } - const imageData = getImage(image); const cardTitle = headerLeadText ? `${headerLeadText}: ${title}` : title; @@ -36,13 +36,11 @@ const DiagramCard: React.FC = ({ caption={caption} className={classNames(container, className)} > - {imageData && ( - - )} + ); }; diff --git a/src/components/shared/ImageRenderer.tsx b/src/components/shared/ImageRenderer.tsx new file mode 100644 index 00000000..90fa15ff --- /dev/null +++ b/src/components/shared/ImageRenderer.tsx @@ -0,0 +1,50 @@ +import { GatsbyImage, IGatsbyImageData, getImage } from "gatsby-plugin-image"; +import React from "react"; + +import { ImageSource } from "../../component-queries/types"; +import { isExternalUrl } from "../../utils/mediaUtils"; + +interface ImageRendererProps { + image: ImageSource; + alt: string; + className?: string; + style?: React.CSSProperties; + imgStyle?: React.CSSProperties; + onClick?: () => void; +} + +// handle both Gatsby image data and external URLs +const ImageRenderer: React.FC = ({ + alt, + className, + image, + imgStyle, + onClick, + style, +}) => { + if (isExternalUrl(image)) { + return ( + {alt} + ); + } + const resolved = getImage(image as IGatsbyImageData); + if (!resolved) return null; + return ( + + ); +}; + +export default ImageRenderer; diff --git a/src/templates/cell-line.tsx b/src/templates/cell-line.tsx index 11a287cd..cb8346e9 100644 --- a/src/templates/cell-line.tsx +++ b/src/templates/cell-line.tsx @@ -178,6 +178,7 @@ export const pageQuery = graphql` ) } } + image_url caption } videos { @@ -201,6 +202,7 @@ export const pageQuery = graphql` ) } } + image_url caption } } @@ -217,6 +219,7 @@ export const pageQuery = graphql` ) } } + image_url caption } } @@ -269,6 +272,7 @@ export const pageQuery = graphql` ) } } + image_url caption } } diff --git a/src/templates/disease-cell-line.tsx b/src/templates/disease-cell-line.tsx index 7e2daace..55c05ad5 100644 --- a/src/templates/disease-cell-line.tsx +++ b/src/templates/disease-cell-line.tsx @@ -194,6 +194,7 @@ export const pageQuery = graphql` ) } } + image_url caption } } @@ -217,6 +218,7 @@ export const pageQuery = graphql` ) } } + image_url caption } } @@ -232,6 +234,7 @@ export const pageQuery = graphql` ) } } + image_url caption } } diff --git a/src/utils/mediaUtils.ts b/src/utils/mediaUtils.ts index 497cb2ad..db9512ec 100644 --- a/src/utils/mediaUtils.ts +++ b/src/utils/mediaUtils.ts @@ -1,38 +1,65 @@ -import { getImage, IGatsbyImageData } from "gatsby-plugin-image"; -import { RawImageData, UnpackedImageData, RawVideoData, MediaFrontmatter, ImageOrVideo } from "../component-queries/types"; +import { getImage } from "gatsby-plugin-image"; import { FileNode } from "gatsby-plugin-image/dist/src/components/hooks"; +import { + ImageOrVideo, + ImageSource, + MediaFrontmatter, + RawImageData, + RawVideoData, + UnpackedImageData, +} from "../component-queries/types"; + +export function isExternalUrl(image: ImageSource): image is string { + return typeof image === "string"; +} + // type guard to distinguish images and videos at runtime export function isImage(item: ImageOrVideo): item is UnpackedImageData { - return "image" in item; + return "image" in item; } -export const getImageSrcFromFileNode = (file: FileNode): string | undefined => { - return file.childImageSharp?.gatsbyImageData?.images?.fallback?.src; -} +export const getImageSrcFromFileNode = (file: FileNode): string | undefined => { + return file.childImageSharp?.gatsbyImageData?.images?.fallback?.src; +}; // flatten and validate image data -export function unpackImageData( - x: RawImageData -): UnpackedImageData | null { - return { image: x.image.childImageSharp.gatsbyImageData, caption: x.caption }; +export function unpackImageData(x: RawImageData): UnpackedImageData | null { + if (!x) return null; + // Gatsby image: image.childImageSharp.gatsbyImageData + if (x.image?.childImageSharp?.gatsbyImageData) { + return { + image: x.image.childImageSharp.gatsbyImageData, + caption: x.caption, + }; + } + // Cloudinary url: image is null (file can't resolve), use image_url + if (x.image_url) { + return { image: x.image_url, caption: x.caption }; + } + return null; } export const hasMedia = (rawMedia?: MediaFrontmatter): boolean => { - return Boolean(rawMedia?.images?.length || rawMedia?.videos?.length); + return Boolean(rawMedia?.images?.length || rawMedia?.videos?.length); }; export const getImages = (raw?: MediaFrontmatter): UnpackedImageData[] => { - const media = (raw?.images ?? []); - return media.map(unpackImageData) - .filter((x): x is UnpackedImageData => x !== null); -} + const media = raw?.images ?? []; + return media + .map(unpackImageData) + .filter((x): x is UnpackedImageData => x !== null); +}; export const getVideos = (rawMedia?: MediaFrontmatter): RawVideoData[] => { - return rawMedia?.videos || []; + return rawMedia?.videos || []; }; -export const getThumbnail = (imagesAndVideos?: MediaFrontmatter): IGatsbyImageData | null => { - const firstImage = getImages(imagesAndVideos)[0]; - return firstImage ? getImage(firstImage.image) ?? null : null; -}; \ No newline at end of file +export const getThumbnail = ( + imagesAndVideos?: MediaFrontmatter, +): ImageSource | null => { + const firstImage = getImages(imagesAndVideos)[0]; + if (!firstImage) return null; + if (isExternalUrl(firstImage.image)) return firstImage.image; + return getImage(firstImage.image) ?? null; +}; diff --git a/static/admin/config.yml b/static/admin/config.yml index a23327e5..766c5136 100644 --- a/static/admin/config.yml +++ b/static/admin/config.yml @@ -34,7 +34,7 @@ richTextFields: &richTextFields - { label: "Text", name: "text", widget: "string" } - { label: "URL", name: "url", widget: "string" } -field_image: &field_image { label: "Image", name: "image", widget: "image" } +field_image: &field_image { label: "Image", name: "image", widget: "cloudinary-image" } field_caption: &field_caption { label: "Caption", @@ -93,7 +93,7 @@ header: &header - { label: "Background Image", name: "background", - widget: "image", + widget: "cloudinary-image", required: false, } @@ -571,7 +571,7 @@ collections: { label: "Image", name: "image", - widget: "image", + widget: "cloudinary-image", }, { label: "Caption",