-
Notifications
You must be signed in to change notification settings - Fork 0
add cloudinary image upload support #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 12 commits
b393ea8
d1e9cfa
85fda51
d20693b
1430439
2bfc2b5
c618d37
ff08245
a082cfd
22f73f2
d3ba66c
e63036a
941ea56
6f09b16
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
rugeli marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| }); | ||
|
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; | ||
| } | ||
|
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); | ||
|
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 }; | ||
There was a problem hiding this comment.
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!