Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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,
});
};

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";
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;
}

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";
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<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;
}

// 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);
}, [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