Skip to content

add cloudinary image upload support #428

Open
rugeli wants to merge 13 commits intomainfrom
gallery
Open

add cloudinary image upload support #428
rugeli wants to merge 13 commits intomainfrom
gallery

Conversation

@rugeli
Copy link
Copy Markdown
Collaborator

@rugeli rugeli commented Apr 7, 2026

Problem

What is the problem this work solves, including
step 1 of #88

Solution

What I/we did to solve this problem

  • added a custom widget that opens the cloudinary upload widget , auto-derives upload folders from the cms entry.
    • one folder per cell line, e.g. cell-lines/AICS-{id}-{clone}
    • on successful upload, the widget stores the cloudinary img url into markdown frontmatter image field
  • created a shared component ImageRenderer to handle both gastby images(GatsbyImage) and external urls (<img>)
  • added image_url field via createResolvers in gatsby-node.js.
    • for a local image, image resolves to GatsbyImage, image_url is null
    • for cloudinary image urls, image is null and image_url carries the url

Notes:
This is mainly to show how Cloudinary can work with our current image management. With this change, images can now be uploaded to Cloudinary, while local images rendering still works the same as it does on main.

Things we can decide separately later:

  • whether porting the existing local images in this repo to cloud storage
  • in idea-board, implement a similar upload flow if we want to move forward with cloud image storage

Not yet implemented:

  • removing images from Cloudinary folders, except through the Cloudinary UI

Type of change

Please delete options that are not relevant.

  • New feature (non-breaking change which adds functionality)

Steps to Verify:

  1. on admin page, edit a cell line entry and use the new image upload widget
  2. cloudinary upload widget should open and upload to the correct folder, link to gallery (lmk if you have any issues opening or viewing)
  3. all images on the site should be rendered

Screenshots (optional):

Show-n-tell images/animations here

  • in Cloudinary, images are stored in designated folder structure
Screenshot 2026-04-07 at 2 51 54 PM
  • on the admin page, users can upload images to the cell line folder in Cloudinary. Local image preview remains unchanged.
Screenshot 2026-04-07 at 2 59 36 PM
  • but if clicks on Replace Image for a local image, the replacement image is uploaded to Cloudinary instead.
Screenshot 2026-04-07 at 3 22 51 PM

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 7, 2026

Deploy Preview for cell-catalog ready!

Name Link
🔨 Latest commit 941ea56
🔍 Latest deploy log https://app.netlify.com/projects/cell-catalog/deploys/69e6b736e0bc3f0008b2bc49
😎 Deploy Preview https://deploy-preview-428--cell-catalog.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@rugeli rugeli marked this pull request as ready for review April 20, 2026 21:34
@rugeli rugeli requested a review from Copilot April 20, 2026 21:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Cloudinary-backed image uploads to the Decap CMS admin flow while preserving existing local-image rendering by introducing a dual image model (Gatsby image data vs external URL) and plumbing that URL through Gatsby GraphQL.

Changes:

  • Added a custom Decap CMS widget (cloudinary-image) that uploads to Cloudinary and writes the resulting URL into frontmatter.
  • Introduced ImageRenderer + ImageSource to render either GatsbyImage data or external <img src=...> URLs, and updated consumers accordingly.
  • Added an image_url GraphQL field (via createResolvers) and updated relevant queries to fetch it alongside existing image fields.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
static/admin/config.yml Switches image widgets to the new cloudinary-image widget (including header background).
src/utils/mediaUtils.ts Adds isExternalUrl, updates image unpacking to support image_url, and changes thumbnail extraction to return ImageSource.
src/templates/disease-cell-line.tsx Queries image_url alongside existing image fields.
src/templates/cell-line.tsx Queries image_url alongside existing image fields (including media + diagrams + rnaseq).
src/style/images-and-videos.module.css Adjusts media container height styling.
src/components/shared/ImageRenderer.tsx New shared renderer for Gatsby images vs external URLs.
src/components/shared/DiagramCard.tsx Uses ImageRenderer and updates prop typing to accept ImageSource.
src/components/Thumbnail.tsx Uses ImageRenderer and updates prop typing to accept ImageSource.
src/components/SubPage/convert-data.ts Uses unpackImageData to normalize images into ImageSource.
src/components/ParentalLineModal.tsx Updates thumbnail rendering to support ImageSource via ImageRenderer.
src/components/ImagesAndVideo/MediaCard.tsx Replaces direct GatsbyImage usage with ImageRenderer.
src/components/ImagesAndVideo/ImagePreviewGroup.tsx Supports previewing external URLs by bypassing getSrc when needed.
src/components/CellLineTable/SharedColumns.tsx Updates thumbnail rendering logic to support external URLs.
src/component-queries/types.ts Introduces ImageSource and updates frontmatter/result types for Cloudinary URL support.
src/component-queries/NormalCellLines.tsx Adds image_url to the table query.
src/component-queries/DiseaseCellLines.tsx Adds image_url to the disease table query.
src/cms/widgets/CloudinaryImageWidget.tsx New Cloudinary upload widget implementation (script loading, folder derivation, preview).
src/cms/cms.tsx Registers the custom cloudinary-image widget with Decap CMS.
src/cms/cloudinaryConfig.ts Adds Cloudinary widget configuration constants.
gatsby-node.js Adds schema/types/resolvers for image_url and introduces an ImagesAndVideos type.
Comments suppressed due to low confidence (1)

gatsby-node.js:68

  • ImagesAndVideos is now explicitly typed but only includes images. Existing GraphQL queries in src/templates/cell-line.tsx request images_and_videos { videos { ... } }, so this schema will either drop the videos field or rely on inference (which is brittle) and can break builds. Define a VideoWithCaption type and add videos: [VideoWithCaption] to ImagesAndVideos (or otherwise include the videos field explicitly) so the schema matches frontmatter usage.
        ` type ImgWithCaption {
            image: File @fileByRelativePath
            caption: String
            }
            type ImagesAndVideos {
            images: [ImgWithCaption]
            }
            type Diagram {
            title: String
            images: [ImgWithCaption]
            }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +195 to +215
const [localSrc, setLocalSrc] = useState<string | null>(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);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preview polling interval can run indefinitely if getAsset(value, field).toString() never changes (e.g., it already returns a stable value, or it returns an unresolved placeholder forever). Add a max attempt/time limit (and/or stop polling once a non-empty, non-"undefined" src is observed) to avoid leaking intervals while the editor stays open.

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +6
export const CLOUDINARY_CLOUD_NAME = "dkg6lnogl";
export const CLOUDINARY_UPLOAD_PRESET = "allen-cell";
export const CLOUDINARY_API_KEY = "989839737788897";
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloudinary identifiers are hardcoded in-repo. Even though the API key isn’t a secret, hardcoding the cloud name/preset/key makes it difficult to deploy forks or alternate environments (preview vs prod) safely. Prefer reading these from environment variables (e.g., process.env.GATSBY_CLOUDINARY_*) with a clear fallback/error so configuration is deploy-time, not code-time.

Suggested change
export const CLOUDINARY_CLOUD_NAME = "dkg6lnogl";
export const CLOUDINARY_UPLOAD_PRESET = "allen-cell";
export const CLOUDINARY_API_KEY = "989839737788897";
const getRequiredEnvVar = (name: string): string => {
const value = process.env[name];
if (!value || !value.trim()) {
throw new Error(
`Missing required Cloudinary configuration: ${name}. ` +
"Set this environment variable at deploy time."
);
}
return value;
};
export const CLOUDINARY_CLOUD_NAME = getRequiredEnvVar(
"GATSBY_CLOUDINARY_CLOUD_NAME"
);
export const CLOUDINARY_UPLOAD_PRESET = getRequiredEnvVar(
"GATSBY_CLOUDINARY_UPLOAD_PRESET"
);
export const CLOUDINARY_API_KEY = getRequiredEnvVar(
"GATSBY_CLOUDINARY_API_KEY"
);

Copilot uses AI. Check for mistakes.
Comment thread static/admin/config.yml
label: "Background Image",
name: "background",
widget: "image",
widget: "cloudinary-image",
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header background field is switched to cloudinary-image, which will store a URL string in frontmatter. Pages like src/templates/disease-catalog.tsx currently treat header.background as a FileNode and even use a non-null assertion (imageFile!) before calling getImageSrcFromFileNode, so a Cloudinary URL (which resolves background to null via @fileByRelativePath) can cause a runtime crash. Either keep this field on the standard image widget, or add parallel URL support (e.g., a background_url resolver + template changes to handle both).

Suggested change
widget: "cloudinary-image",
widget: "image",

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +62
function loadCloudinaryScript(): Promise<void> {
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);
});
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadCloudinaryScript() only gates on a boolean (scriptLoaded). If openUploader is triggered multiple times before the script finishes loading, this will append multiple <script> tags and race the global initialization. Cache the in-flight Promise (e.g., let scriptPromise: Promise<void> | null) and return it while loading to guarantee a single script insertion.

Suggested change
function loadCloudinaryScript(): Promise<void> {
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);
});
let scriptPromise: Promise<void> | null = null;
function loadCloudinaryScript(): Promise<void> {
if (scriptLoaded) return Promise.resolve();
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src =
"https://upload-widget.cloudinary.com/latest/global/all.js";
script.onload = () => {
scriptLoaded = true;
scriptPromise = null;
resolve();
};
script.onerror = () => {
scriptPromise = null;
reject(new Error("Failed to load Cloudinary Upload Widget script"));
};
document.head.appendChild(script);
});
return scriptPromise;

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +123
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;
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openUploader awaits loadCloudinaryScript() but doesn’t handle rejections. If the script fails to load (network/CSP), this async handler will throw and can surface as an unhandled promise rejection in the CMS UI. Wrap the await in a try/catch and show a user-visible error state (or at least log and return).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants