Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions gatsby-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ exports.createSchemaCustomization = ({ actions, schema }) => {
image: File @fileByRelativePath
caption: String
}
type ImagesAndVideos {
images: [ImgWithCaption]
}
type Diagram {
title: String
images: [ImgWithCaption]
Expand Down Expand Up @@ -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]
Expand All @@ -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,
});
};

Comment on lines +121 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Love bringing the resolver pattern over into cell catalog!

exports.createPages = ({ actions, graphql }) => {
const { createPage } = actions;

Expand Down
6 changes: 6 additions & 0 deletions src/cms/cloudinaryConfig.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
rugeli marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Trying to believe the comment about about public facing, but it does smell strange to have something called API_KEY committed publicly! Could use .env? Maybe its fine.

13 changes: 13 additions & 0 deletions src/cms/cms.tsx
Original file line number Diff line number Diff line change
@@ -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",
);
Expand Down
314 changes: 314 additions & 0 deletions src/cms/widgets/CloudinaryImageWidget.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +10 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Too bad we can't easily share this kind of code between cell catalog and idea board.


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<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";
Comment on lines +54 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe use a constannt?

script.onload = () => {
scriptLoaded = true;
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
Comment thread
rugeli marked this conversation as resolved.
}

/**
* 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<CloudinaryWidgetProps> = ({
entry,
field,
getAsset,
onChange,
value,
}) => {
const widgetRef = useRef<CloudinaryWidgetInstance | null>(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;
}
Comment thread
rugeli marked this conversation as resolved.

// 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<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);
Comment thread
rugeli marked this conversation as resolved.
}, [value, field, getAsset, hasImage, isUrl]);

const previewSrc = isUrl ? value : localSrc;

return (
<div
style={{
border: "1px solid #ddd",
borderRadius: "4px",
padding: "12px",
}}
>
{hasImage && previewSrc && (
<div style={{ marginBottom: "8px" }}>
<img
src={previewSrc}
alt="Uploaded"
style={{
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
borderRadius: "4px",
}}
/>
<div
style={{
fontSize: "11px",
color: "#888",
marginTop: "4px",
wordBreak: "break-all",
}}
>
{value}
</div>
</div>
)}

<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<button
type="button"
onClick={openUploader}
style={{
padding: "8px 16px",
backgroundColor: "#607E96",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
}}
>
{hasImage ? "Replace Image" : "Upload Image"}
</button>

{hasImage && (
<button
type="button"
onClick={() => onChange("")}
style={{
padding: "8px 16px",
backgroundColor: "#f5f5f5",
color: "#333",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
}}
>
Remove
</button>
)}
</div>

<div
style={{
fontSize: "11px",
color: "#888",
marginTop: "8px",
}}
>
Uploads to: <strong>{folder}</strong>
</div>
</div>
);
};

// Preview component for the CMS preview pane
const CloudinaryImagePreview: React.FC<{ value?: string }> = ({ value }) => {
if (!value) return null;
return (
<img
src={value}
alt="Preview"
style={{ maxWidth: "100%", maxHeight: "300px" }}
/>
);
};

export { CloudinaryImageWidget, CloudinaryImagePreview };
Loading
Loading