From b393ea8b4a8bb57c573628efee6c95f940413edf Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Wed, 25 Mar 2026 10:39:40 -0700 Subject: [PATCH 01/11] custom widget --- src/cms/cms.tsx | 13 + src/cms/widgets/CloudinaryImageWidget.tsx | 279 ++++++++++++++++++++++ static/admin/config.yml | 6 +- 3 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 src/cms/widgets/CloudinaryImageWidget.tsx 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..805f5125 --- /dev/null +++ b/src/cms/widgets/CloudinaryImageWidget.tsx @@ -0,0 +1,279 @@ +import React, { useCallback, useEffect, useRef } from "react"; + +// Cloudinary configuration(temp) +const CLOUD_NAME = "dkg6lnogl"; +const UPLOAD_PRESET = "allen-cell"; +const API_KEY = "989839737788897"; + +// Decap CMS passes Immutable.js Maps — type the minimal surface we use +interface ImmutableMap { + get: (key: string) => unknown; +} + +interface CloudinaryWidgetProps { + value?: string; + onChange: (value: string) => void; + entry: ImmutableMap; +} + +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, + 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: CLOUD_NAME, + uploadPreset: UPLOAD_PRESET, + apiKey: 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; + + return ( +
+ {hasImage && ( +
+ 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/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", From d1e9cfa2c3c3bcd2d8c5374a1a71b384bfd8a613 Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Wed, 25 Mar 2026 10:41:05 -0700 Subject: [PATCH 02/11] accept image_url --- gatsby-node.js | 24 ++++++++++++++++++++++ src/component-queries/DiseaseCellLines.tsx | 1 + src/component-queries/NormalCellLines.tsx | 1 + src/templates/cell-line.tsx | 4 ++++ src/templates/disease-cell-line.tsx | 3 +++ 5 files changed, 33 insertions(+) diff --git a/gatsby-node.js b/gatsby-node.js index b4b6da80..fc0649aa 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -108,6 +108,30 @@ 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, + // Gatsby-inferred types for image lists not covered by ImgWithCaption + MarkdownRemarkFrontmatterImages_and_videosImages: imageUrlResolver, + MarkdownRemarkFrontmatterEditing_designDiagramsImages: imageUrlResolver, + Diagram: imageUrlResolver, + }); +}; + exports.createPages = ({ actions, graphql }) => { const { createPage } = actions; 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/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 } } From 85fda51084137f8718290458c5f1e0b5ffe754bf Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Wed, 25 Mar 2026 10:43:34 -0700 Subject: [PATCH 03/11] image url type and util --- src/component-queries/types.ts | 10 +++-- src/utils/mediaUtils.ts | 67 ++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 23 deletions(-) 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/utils/mediaUtils.ts b/src/utils/mediaUtils.ts index 497cb2ad..0becf8c8 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 isCloudinaryUrl(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 (isCloudinaryUrl(firstImage.image)) return firstImage.image; + return getImage(firstImage.image) ?? null; +}; From d20693bf1313e2d5d015a09c83d5b6b804c1e6d4 Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Wed, 25 Mar 2026 10:45:35 -0700 Subject: [PATCH 04/11] add render image url --- .../CellLineTable/SharedColumns.tsx | 45 ++++++++++++------- .../ImagesAndVideo/ImagePreviewGroup.tsx | 6 ++- src/components/ImagesAndVideo/MediaCard.tsx | 17 ++++++- src/components/ParentalLineModal.tsx | 24 +++++++--- src/components/Thumbnail.tsx | 30 ++++++++++++- src/components/shared/DiagramCard.tsx | 19 +++++--- 6 files changed, 112 insertions(+), 29 deletions(-) diff --git a/src/components/CellLineTable/SharedColumns.tsx b/src/components/CellLineTable/SharedColumns.tsx index 3e7fce96..02c016f3 100644 --- a/src/components/CellLineTable/SharedColumns.tsx +++ b/src/components/CellLineTable/SharedColumns.tsx @@ -2,7 +2,7 @@ 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 { GatsbyImage, IGatsbyImageData, getImage } from "gatsby-plugin-image"; import React from "react"; import { CellLineStatus } from "../../component-queries/types"; @@ -34,21 +34,36 @@ export const cellLineIdColumn = { const cellLine = (

{formatCellLineId(cellLineId)}

); - const thumbnailImage = getImage(record.thumbnailImage || null); + const thumbnailSrc = record.thumbnailImage; + const isUrl = typeof thumbnailSrc === "string"; + const gatsbyImage = !isUrl ? getImage(thumbnailSrc || null) : null; - const content = thumbnailImage ? ( - <> -
{cellLine}
-
- -
- - ) : ( -
{cellLine}
- ); + const content = + isUrl || gatsbyImage ? ( + <> +
{cellLine}
+
+ {isUrl ? ( + {`${cellLine} + ) : ( + + )} +
+ + ) : ( +
{cellLine}
+ ); return record.status === CellLineStatus.DataComplete ? ( {content} diff --git a/src/components/ImagesAndVideo/ImagePreviewGroup.tsx b/src/components/ImagesAndVideo/ImagePreviewGroup.tsx index 0a4b4331..86384065 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 { isCloudinaryUrl } from "../../utils/mediaUtils"; interface ImagePreviewGroupProps { mediaItems: ImageOrVideo[]; @@ -30,10 +31,13 @@ export const PreviewGroup = ({ setSelectedMedia, }: ImagePreviewGroupProps) => { const allPreviewImages = imageItems.map((item, i) => { + const src = isCloudinaryUrl(item.image) + ? item.image + : getSrc(item.image); return ( diff --git a/src/components/ImagesAndVideo/MediaCard.tsx b/src/components/ImagesAndVideo/MediaCard.tsx index 8fb29744..65436bef 100644 --- a/src/components/ImagesAndVideo/MediaCard.tsx +++ b/src/components/ImagesAndVideo/MediaCard.tsx @@ -3,7 +3,7 @@ import { GatsbyImage } from "gatsby-plugin-image"; import React from "react"; import { ImageOrVideo } from "../../component-queries/types"; -import { isImage } from "../../utils/mediaUtils"; +import { isCloudinaryUrl, isImage } from "../../utils/mediaUtils"; const { caption, @@ -35,6 +35,21 @@ const renderMediaContent = ( const isImageItem = isImage(item); if (isImageItem) { + if (isCloudinaryUrl(item.image)) { + return ( + Cell line media + ); + } return ( { props.suppressRowClickRef.current = false; setIsModalOpen(false); }; - const image = getImage(props.image ?? null); + const rawImage = props.image; + const isUrl = rawImage ? isCloudinaryUrl(rawImage) : false; + const image = + !isUrl && rawImage ? getImage(rawImage as IGatsbyImageData) : null; const headerElement = (
Parental Line
@@ -120,12 +124,22 @@ const ParentalLineModal = (props: ParentalLineModalProps) => { >
- {image && ( + {isUrl && rawImage ? ( + {`${props.formattedId} + ) : image ? ( - )} + ) : null}
void; @@ -28,6 +31,29 @@ const Thumbnail: React.FC = ({ type = "image", videoId, }) => { + const renderImage = () => { + if (!image) return null; + if (isCloudinaryUrl(image)) { + return ( + thumbnail image + ); + } + return ( + + ); + }; + return (
= ({ role="button" > {type === "image" && image ? ( - + renderImage() ) : ( = ({ if (!image) { return null; } - const imageData = getImage(image); const cardTitle = headerLeadText ? `${headerLeadText}: ${title}` : title; + const isUrl = isCloudinaryUrl(image); + const imageData = !isUrl ? getImage(image) : null; return ( = ({ caption={caption} className={classNames(container, className)} > - {imageData && ( + {isUrl ? ( + {cardTitle + ) : imageData ? ( - )} + ) : null} ); }; From 1430439687431c75e93ade0a1ee6eb60e9b31d21 Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Tue, 7 Apr 2026 12:46:13 -0700 Subject: [PATCH 05/11] generalize image url function name --- src/components/ImagesAndVideo/ImagePreviewGroup.tsx | 6 ++---- src/components/ImagesAndVideo/MediaCard.tsx | 4 ++-- src/components/ParentalLineModal.tsx | 4 ++-- src/components/Thumbnail.tsx | 4 ++-- src/components/shared/DiagramCard.tsx | 4 ++-- src/utils/mediaUtils.ts | 4 ++-- 6 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/ImagesAndVideo/ImagePreviewGroup.tsx b/src/components/ImagesAndVideo/ImagePreviewGroup.tsx index 86384065..d310ca3a 100644 --- a/src/components/ImagesAndVideo/ImagePreviewGroup.tsx +++ b/src/components/ImagesAndVideo/ImagePreviewGroup.tsx @@ -4,7 +4,7 @@ import { getSrc } from "gatsby-plugin-image"; import React from "react"; import { ImageOrVideo, UnpackedImageData } from "../../component-queries/types"; -import { isCloudinaryUrl } from "../../utils/mediaUtils"; +import { isExternalUrl } from "../../utils/mediaUtils"; interface ImagePreviewGroupProps { mediaItems: ImageOrVideo[]; @@ -31,9 +31,7 @@ export const PreviewGroup = ({ setSelectedMedia, }: ImagePreviewGroupProps) => { const allPreviewImages = imageItems.map((item, i) => { - const src = isCloudinaryUrl(item.image) - ? item.image - : getSrc(item.image); + const src = isExternalUrl(item.image) ? item.image : getSrc(item.image); return ( { setIsModalOpen(false); }; const rawImage = props.image; - const isUrl = rawImage ? isCloudinaryUrl(rawImage) : false; + const isUrl = rawImage ? isExternalUrl(rawImage) : false; const image = !isUrl && rawImage ? getImage(rawImage as IGatsbyImageData) : null; const headerElement = ( diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx index 52242074..f3bcbf43 100644 --- a/src/components/Thumbnail.tsx +++ b/src/components/Thumbnail.tsx @@ -3,7 +3,7 @@ import { GatsbyImage, IGatsbyImageData } from "gatsby-plugin-image"; import React from "react"; import { ImageSource } from "../component-queries/types"; -import { isCloudinaryUrl } from "../utils/mediaUtils"; +import { isExternalUrl } from "../utils/mediaUtils"; const { selectedThumbnail, @@ -33,7 +33,7 @@ const Thumbnail: React.FC = ({ }) => { const renderImage = () => { if (!image) return null; - if (isCloudinaryUrl(image)) { + if (isExternalUrl(image)) { return ( = ({ } const cardTitle = headerLeadText ? `${headerLeadText}: ${title}` : title; - const isUrl = isCloudinaryUrl(image); + const isUrl = isExternalUrl(image); const imageData = !isUrl ? getImage(image) : null; return ( diff --git a/src/utils/mediaUtils.ts b/src/utils/mediaUtils.ts index 0becf8c8..db9512ec 100644 --- a/src/utils/mediaUtils.ts +++ b/src/utils/mediaUtils.ts @@ -10,7 +10,7 @@ import { UnpackedImageData, } from "../component-queries/types"; -export function isCloudinaryUrl(image: ImageSource): image is string { +export function isExternalUrl(image: ImageSource): image is string { return typeof image === "string"; } @@ -60,6 +60,6 @@ export const getThumbnail = ( ): ImageSource | null => { const firstImage = getImages(imagesAndVideos)[0]; if (!firstImage) return null; - if (isCloudinaryUrl(firstImage.image)) return firstImage.image; + if (isExternalUrl(firstImage.image)) return firstImage.image; return getImage(firstImage.image) ?? null; }; From 2bfc2b5a3a9da876d40f3b34974813c490d367dc Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Tue, 7 Apr 2026 13:03:29 -0700 Subject: [PATCH 06/11] refactor: new prop ImageRenderer --- .../CellLineTable/SharedColumns.tsx | 53 +++++++++---------- src/components/ImagesAndVideo/MediaCard.tsx | 26 ++------- src/components/ParentalLineModal.tsx | 19 ++----- src/components/Thumbnail.tsx | 36 ++++--------- src/components/shared/DiagramCard.tsx | 23 +++----- src/components/shared/ImageRenderer.tsx | 45 ++++++++++++++++ 6 files changed, 95 insertions(+), 107 deletions(-) create mode 100644 src/components/shared/ImageRenderer.tsx diff --git a/src/components/CellLineTable/SharedColumns.tsx b/src/components/CellLineTable/SharedColumns.tsx index 02c016f3..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, IGatsbyImageData, 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 { @@ -35,35 +37,28 @@ export const cellLineIdColumn = {

{formatCellLineId(cellLineId)}

); const thumbnailSrc = record.thumbnailImage; - const isUrl = typeof thumbnailSrc === "string"; - const gatsbyImage = !isUrl ? getImage(thumbnailSrc || null) : null; + const hasImage = + thumbnailSrc && + (isExternalUrl(thumbnailSrc) || getImage(thumbnailSrc)); - const content = - isUrl || gatsbyImage ? ( - <> -
{cellLine}
-
- {isUrl ? ( - {`${cellLine} - ) : ( - - )} -
- - ) : ( -
{cellLine}
- ); + const content = hasImage ? ( + <> +
{cellLine}
+
+ +
+ + ) : ( +
{cellLine}
+ ); return record.status === CellLineStatus.DataComplete ? ( {content} diff --git a/src/components/ImagesAndVideo/MediaCard.tsx b/src/components/ImagesAndVideo/MediaCard.tsx index fd3ae100..82337801 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 { isExternalUrl, isImage } from "../../utils/mediaUtils"; +import { isImage } from "../../utils/mediaUtils"; +import ImageRenderer from "../shared/ImageRenderer"; const { caption, @@ -35,28 +35,12 @@ const renderMediaContent = ( const isImageItem = isImage(item); if (isImageItem) { - if (isExternalUrl(item.image)) { - return ( - Cell line media - ); - } return ( - ); diff --git a/src/components/ParentalLineModal.tsx b/src/components/ParentalLineModal.tsx index 5cbae376..54b548d5 100644 --- a/src/components/ParentalLineModal.tsx +++ b/src/components/ParentalLineModal.tsx @@ -1,13 +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 { ImageSource, UnpackedGene } from "../component-queries/types"; import LinkOutIcon from "../img/external-link.svg"; import { formatCellLineSlug } from "../utils"; -import { isExternalUrl } from "../utils/mediaUtils"; import { DarkBlueHoverButton } from "./shared/Buttons"; +import ImageRenderer from "./shared/ImageRenderer"; const { actionButton, @@ -45,9 +44,6 @@ const ParentalLineModal = (props: ParentalLineModalProps) => { setIsModalOpen(false); }; const rawImage = props.image; - const isUrl = rawImage ? isExternalUrl(rawImage) : false; - const image = - !isUrl && rawImage ? getImage(rawImage as IGatsbyImageData) : null; const headerElement = (
Parental Line
@@ -124,22 +120,17 @@ const ParentalLineModal = (props: ParentalLineModalProps) => { >
- {isUrl && rawImage ? ( - {`${props.formattedId} - ) : image ? ( - - ) : null} + )}
= ({ type = "image", videoId, }) => { - const renderImage = () => { - if (!image) return null; - if (isExternalUrl(image)) { - return ( - thumbnail image - ); - } - return ( - - ); - }; - return (
= ({ role="button" > {type === "image" && image ? ( - renderImage() + ) : ( = ({ } const cardTitle = headerLeadText ? `${headerLeadText}: ${title}` : title; - const isUrl = isExternalUrl(image); - const imageData = !isUrl ? getImage(image) : null; return ( = ({ caption={caption} className={classNames(container, className)} > - {isUrl ? ( - {cardTitle - ) : imageData ? ( - - ) : null} + ); }; diff --git a/src/components/shared/ImageRenderer.tsx b/src/components/shared/ImageRenderer.tsx new file mode 100644 index 00000000..8f411caa --- /dev/null +++ b/src/components/shared/ImageRenderer.tsx @@ -0,0 +1,45 @@ +import { GatsbyImage, IGatsbyImageData } 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; + onClick?: () => void; +} + +// handle both Gatsby image data and external URLs +const ImageRenderer: React.FC = ({ + alt, + className, + image, + onClick, + style, +}) => { + if (isExternalUrl(image)) { + return ( + {alt} + ); + } + return ( + + ); +}; + +export default ImageRenderer; From c618d37cae8471852d2434c2304dc6ed50195e7e Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Tue, 7 Apr 2026 13:11:27 -0700 Subject: [PATCH 07/11] use constants for cloudinary config --- src/cms/cloudinaryConfig.ts | 6 ++++++ src/cms/widgets/CloudinaryImageWidget.tsx | 15 ++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 src/cms/cloudinaryConfig.ts 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/widgets/CloudinaryImageWidget.tsx b/src/cms/widgets/CloudinaryImageWidget.tsx index 805f5125..9428a55d 100644 --- a/src/cms/widgets/CloudinaryImageWidget.tsx +++ b/src/cms/widgets/CloudinaryImageWidget.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useRef } from "react"; -// Cloudinary configuration(temp) -const CLOUD_NAME = "dkg6lnogl"; -const UPLOAD_PRESET = "allen-cell"; -const API_KEY = "989839737788897"; +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 { @@ -120,9 +121,9 @@ const CloudinaryImageWidget: React.FC = ({ widgetRef.current = cloudinary.createUploadWidget( { - cloudName: CLOUD_NAME, - uploadPreset: UPLOAD_PRESET, - apiKey: API_KEY, + cloudName: CLOUDINARY_CLOUD_NAME, + uploadPreset: CLOUDINARY_UPLOAD_PRESET, + apiKey: CLOUDINARY_API_KEY, folder: folder, sources: ["local", "url", "camera"], multiple: false, From ff082455ba309873db8a7eabde7ddb1c9ca1d4cc Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Tue, 7 Apr 2026 14:02:13 -0700 Subject: [PATCH 08/11] refactor: add ImgWithCaption ton images --- gatsby-node.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gatsby-node.js b/gatsby-node.js index fc0649aa..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] @@ -125,8 +129,6 @@ exports.createResolvers = ({ createResolvers }) => { createResolvers({ ImgWithCaption: imageUrlResolver, RnaSeqRow: imageUrlResolver, - // Gatsby-inferred types for image lists not covered by ImgWithCaption - MarkdownRemarkFrontmatterImages_and_videosImages: imageUrlResolver, MarkdownRemarkFrontmatterEditing_designDiagramsImages: imageUrlResolver, Diagram: imageUrlResolver, }); From a082cfd9f51c728da04ea4d49943325fdbce207f Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Tue, 7 Apr 2026 15:26:30 -0700 Subject: [PATCH 09/11] maintain local images reviewable in admin page --- src/cms/widgets/CloudinaryImageWidget.tsx | 40 +++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/cms/widgets/CloudinaryImageWidget.tsx b/src/cms/widgets/CloudinaryImageWidget.tsx index 9428a55d..628c1a47 100644 --- a/src/cms/widgets/CloudinaryImageWidget.tsx +++ b/src/cms/widgets/CloudinaryImageWidget.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { CLOUDINARY_API_KEY, @@ -11,10 +11,16 @@ 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 = { @@ -98,6 +104,8 @@ function getFolderFromEntry(entry: ImmutableMap): string { // Cast needed at registration site (cms.tsx) for the same reason. const CloudinaryImageWidget: React.FC = ({ entry, + field, + getAsset, onChange, value, }) => { @@ -182,6 +190,32 @@ const CloudinaryImageWidget: React.FC = ({ 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 (
= ({ padding: "12px", }} > - {hasImage && ( + {hasImage && previewSrc && (
Uploaded Date: Tue, 7 Apr 2026 15:59:32 -0700 Subject: [PATCH 10/11] resolve gatsby image properly --- src/components/ImagesAndVideo/MediaCard.tsx | 3 ++- src/components/shared/ImageRenderer.tsx | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/ImagesAndVideo/MediaCard.tsx b/src/components/ImagesAndVideo/MediaCard.tsx index 82337801..46dbb25f 100644 --- a/src/components/ImagesAndVideo/MediaCard.tsx +++ b/src/components/ImagesAndVideo/MediaCard.tsx @@ -40,7 +40,8 @@ const renderMediaContent = ( image={item.image} alt="Cell line media" className={className} - style={{ objectFit: "contain", maxWidth: "100%" }} + style={{ maxWidth: "100%" }} + imgStyle={{ objectFit: "contain" }} onClick={onClickHandler} /> ); diff --git a/src/components/shared/ImageRenderer.tsx b/src/components/shared/ImageRenderer.tsx index 8f411caa..90fa15ff 100644 --- a/src/components/shared/ImageRenderer.tsx +++ b/src/components/shared/ImageRenderer.tsx @@ -1,4 +1,4 @@ -import { GatsbyImage, IGatsbyImageData } from "gatsby-plugin-image"; +import { GatsbyImage, IGatsbyImageData, getImage } from "gatsby-plugin-image"; import React from "react"; import { ImageSource } from "../../component-queries/types"; @@ -9,6 +9,7 @@ interface ImageRendererProps { alt: string; className?: string; style?: React.CSSProperties; + imgStyle?: React.CSSProperties; onClick?: () => void; } @@ -17,6 +18,7 @@ const ImageRenderer: React.FC = ({ alt, className, image, + imgStyle, onClick, style, }) => { @@ -26,17 +28,20 @@ const ImageRenderer: React.FC = ({ src={image} alt={alt} className={className} - style={style} + style={{ ...style, ...imgStyle }} onClick={onClick} /> ); } + const resolved = getImage(image as IGatsbyImageData); + if (!resolved) return null; return ( ); From d3ba66c23265921b6611e91293f07233da03e469 Mon Sep 17 00:00:00 2001 From: Ruge Li Date: Mon, 13 Apr 2026 14:18:08 -0700 Subject: [PATCH 11/11] fall back to url if image is null --- src/component-queries/types.ts | 6 ++--- src/components/SubPage/convert-data.ts | 37 ++++++++++++++++---------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/component-queries/types.ts b/src/component-queries/types.ts index 3b88553d..703119d8 100644 --- a/src/component-queries/types.ts +++ b/src/component-queries/types.ts @@ -89,7 +89,7 @@ export interface StemCellCharacteristicsFrontmatter { day_of_beating_range: string; }; cardiomyocyte_differentiation_caption: string; - rnaseq_analysis: UnpackedImageData[]; + rnaseq_analysis: RawImageData[]; } export interface ParentalLineFrontmatter { @@ -200,12 +200,12 @@ export interface Sequence { type: string; } -export interface SingleImageDiagram extends UnpackedImageData { +export interface SingleImageDiagram extends RawImageData { title: string; } export interface DiagramList { - images: UnpackedImageData[]; + images: RawImageData[]; title: string; } diff --git a/src/components/SubPage/convert-data.ts b/src/components/SubPage/convert-data.ts index 1506b13d..f8562bc7 100644 --- a/src/components/SubPage/convert-data.ts +++ b/src/components/SubPage/convert-data.ts @@ -14,7 +14,7 @@ import { StemCellCharacteristicsFrontmatter, } from "../../component-queries/types"; import { hasTableData, nonEmptyArray } from "../../utils"; -import { getThumbnail } from "../../utils/mediaUtils"; +import { getThumbnail, unpackImageData } from "../../utils/mediaUtils"; import { DiagramCardProps } from "../shared/DiagramCard"; import { PERCENT_POS_CAPTION } from "./stem-cell-table-constants"; import { @@ -35,13 +35,17 @@ export const unpackDiagrams = ( if (!diagrams || diagrams.length === 0) { return []; } - return diagrams.map((diagram) => { - return { + const result: DiagramCardProps[] = []; + diagrams.forEach((diagram) => { + const unpacked = unpackImageData(diagram); + if (!unpacked) return; + result.push({ title: diagram.title, - caption: diagram.caption, - image: diagram.image, - }; + caption: unpacked.caption, + image: unpacked.image, + }); }); + return result; }; export const unpackMultiImageDiagrams = ( @@ -59,10 +63,12 @@ export const unpackMultiImageDiagrams = ( } diagram.images.forEach((imageObj, index) => { + const unpacked = unpackImageData(imageObj); + if (!unpacked) return; result.push({ title: index === 0 ? diagram.title : "", - caption: imageObj.caption, - image: imageObj.image, + caption: unpacked.caption, + image: unpacked.image, }); }); }); @@ -275,13 +281,16 @@ export const unpackNormalStemCellCharacteristics = ( : [], }; - const rnaSeqAnalysis: DiagramCardProps[] = (scc.rnaseq_analysis ?? []).map( - (item) => ({ + const rnaSeqAnalysis: DiagramCardProps[] = []; + (scc.rnaseq_analysis ?? []).forEach((item) => { + const unpacked = unpackImageData(item); + if (!unpacked) return; + rnaSeqAnalysis.push({ title: "RNASEQ", // TODO get appropriate title for this card - image: item.image, - caption: item.caption, - }), - ); + image: unpacked.image, + caption: unpacked.caption, + }); + }); return { pluripotencyAnalysis,