create-cloudinary-react 1.0.0-beta.13 → 1.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +2 -2
- package/cli.js +1 -1
- package/package.json +1 -1
- package/templates/.cursorrules.template +89 -47
- package/templates/index.html.template +2 -0
- package/templates/src/App.tsx.template +4 -2
- package/templates/src/cloudinary/UploadWidget.tsx.template +91 -46
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.0.0-beta.14](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2026-02-04)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* improve upload widget reliability and add video player poster options ([ac51e42](https://github.com/cloudinary-devs/create-cloudinary-react/commit/ac51e420f1b81f2a055bb3fb7e0331841e86b37a))
|
|
7
|
+
|
|
1
8
|
# [1.0.0-beta.13](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2026-02-03)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npx create-cloudinary-react
|
|
|
22
22
|
The CLI will prompt you for:
|
|
23
23
|
- Project name
|
|
24
24
|
- **Cloudinary cloud name** (found in your [dashboard](https://console.cloudinary.com/app/home/dashboard))
|
|
25
|
-
- Unsigned
|
|
25
|
+
- Unsigned upload preset (optional - required for uploads, but transformations work without it)
|
|
26
26
|
- AI coding assistant(s) you're using (Cursor, GitHub Copilot, Claude, etc.)
|
|
27
27
|
- Whether to install dependencies
|
|
28
28
|
- Whether to start dev server
|
|
@@ -44,7 +44,7 @@ During setup, you'll be asked which AI coding assistant(s) you're using. The CLI
|
|
|
44
44
|
|
|
45
45
|
- ✅ **Cursor** → `.cursorrules` + `.cursor/mcp.json` (if selected)
|
|
46
46
|
- ✅ **GitHub Copilot** → `.github/copilot-instructions.md`
|
|
47
|
-
- ✅ **Claude Code
|
|
47
|
+
- ✅ **Claude Code (VS Code extension)** → `.claude`, `claude.md` + `.cursor/mcp.json` (if selected)
|
|
48
48
|
- ✅ **Generic AI tools** → `AI_INSTRUCTIONS.md`, `PROMPT.md`
|
|
49
49
|
|
|
50
50
|
**MCP Configuration**: The `.cursor/mcp.json` file is automatically generated if you select Cursor or Claude, as it works with both tools.
|
package/cli.js
CHANGED
|
@@ -139,7 +139,7 @@ async function main() {
|
|
|
139
139
|
choices: [
|
|
140
140
|
{ name: 'Cursor', value: 'cursor' },
|
|
141
141
|
{ name: 'GitHub Copilot', value: 'copilot' },
|
|
142
|
-
{ name: 'Claude Code
|
|
142
|
+
{ name: 'Claude Code (VS Code extension)', value: 'claude' },
|
|
143
143
|
{ name: 'Other / Generic AI tools', value: 'generic' },
|
|
144
144
|
],
|
|
145
145
|
default: ['cursor'],
|
package/package.json
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
## Official Documentation
|
|
6
6
|
- **Transformation Rules**: https://cloudinary.com/documentation/cloudinary_transformation_rules.md
|
|
7
|
-
- **Transformation Reference**: https://cloudinary.com/documentation/transformation_reference
|
|
8
|
-
- **React Image Transformations & Plugins**: https://cloudinary.com/documentation/react_image_transformations#plugins
|
|
9
|
-
- **React Video Transformations**: https://cloudinary.com/documentation/react_video_transformations
|
|
10
|
-
- **Cloudinary Video Player** (standalone player): https://cloudinary.com/documentation/cloudinary_video_player
|
|
11
|
-
- **Video Player React Tutorial**: https://cloudinary.com/documentation/video_player_react_tutorial
|
|
12
|
-
- **Upload Widget (signed uploads)**: https://cloudinary.com/documentation/upload_widget#signed_uploads
|
|
13
|
-
- **Upload assets in Next.js (backend signature)**: https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial
|
|
14
|
-
- **Cloudinary Node.js SDK (server-side signing)** — use **v2**: `import { v2 as cloudinary } from 'cloudinary'`; do not use v1 (e.g. 1.47.0). https://cloudinary.com/documentation/node_integration
|
|
15
|
-
- **React Native image and video upload (signed)**: https://cloudinary.com/documentation/react_native_image_and_video_upload#signed_upload
|
|
7
|
+
- **Transformation Reference**: https://cloudinary.com/documentation/transformation_reference.md
|
|
8
|
+
- **React Image Transformations & Plugins**: https://cloudinary.com/documentation/react_image_transformations.md#plugins
|
|
9
|
+
- **React Video Transformations**: https://cloudinary.com/documentation/react_video_transformations.md
|
|
10
|
+
- **Cloudinary Video Player** (standalone player): https://cloudinary.com/documentation/cloudinary_video_player.md
|
|
11
|
+
- **Video Player React Tutorial**: https://cloudinary.com/documentation/video_player_react_tutorial.md
|
|
12
|
+
- **Upload Widget (signed uploads)**: https://cloudinary.com/documentation/upload_widget.md#signed_uploads
|
|
13
|
+
- **Upload assets in Next.js (backend signature)**: https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial.md
|
|
14
|
+
- **Cloudinary Node.js SDK (server-side signing)** — use **v2**: `import { v2 as cloudinary } from 'cloudinary'`; do not use v1 (e.g. 1.47.0). https://cloudinary.com/documentation/node_integration.md
|
|
15
|
+
- **React Native image and video upload (signed)**: https://cloudinary.com/documentation/react_native_image_and_video_upload.md#signed_upload
|
|
16
16
|
- Always consult the official transformation rules when creating transformations
|
|
17
17
|
- Use only officially supported parameters from the transformation reference
|
|
18
18
|
|
|
@@ -48,10 +48,15 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
|
|
|
48
48
|
- Use **this** pattern for the reusable instance. Everywhere else: `import { cld } from './cloudinary/config'` (or the path the user chose) and call `cld.image(publicId)` / `cld.video(publicId)`.
|
|
49
49
|
|
|
50
50
|
**3. Upload Widget (unsigned, from scratch)**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
|
|
52
|
+
**Strict pattern (always follow this exactly):**
|
|
53
|
+
1. **Script in `index.html`** (required): Add `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>` to `index.html`. Do **not** rely only on dynamic script injection from React — it's fragile.
|
|
54
|
+
2. **Poll in useEffect** (required): In `useEffect`, poll with `setInterval` (e.g. every 100ms) until `typeof window.cloudinary?.createUploadWidget === 'function'`. Only then create the widget. A single check (even in `onload`) is **not** reliable because `window.cloudinary` can exist before `createUploadWidget` is attached.
|
|
55
|
+
3. **Add a timeout**: Set a timeout (e.g. 10 seconds) to stop polling and show an error if the script never loads. Clear both interval and timeout in cleanup.
|
|
56
|
+
4. **Create widget once**: When `createUploadWidget` is available, create the widget and store it in a **ref**. Clear the interval and timeout. Pass options: `{ cloudName, uploadPreset, sources: ['local', 'camera', 'url'], multiple: false }`.
|
|
57
|
+
5. **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup.
|
|
58
|
+
|
|
59
|
+
❌ **Do NOT**: Check only `window.cloudinary` (not enough); do a single check in `onload` (unreliable); skip the script in `index.html`; poll forever without a timeout.
|
|
55
60
|
- **Signed uploads**: Do not use only `uploadPreset`; use the pattern under "Secure (Signed) Uploads" (uploadSignature as function, fetch api_key, server includes upload_preset in signature).
|
|
56
61
|
|
|
57
62
|
**4. Video player**
|
|
@@ -60,7 +65,7 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
|
|
|
60
65
|
**5. Summary for rules-only users**
|
|
61
66
|
- **Env**: Use your bundler's client env prefix and access (Vite: `VITE_` + `import.meta.env.VITE_*`; see "Other bundlers" if not Vite).
|
|
62
67
|
- **Reusable instance**: One config file that creates and exports `cld` (and optionally `uploadPreset`) from `@cloudinary/url-gen`; use it everywhere.
|
|
63
|
-
- **Upload widget**: Script in index.html (
|
|
68
|
+
- **Upload widget**: Script in index.html (required); in useEffect, **poll** until `createUploadWidget` is a function, then create widget once and store in ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
|
|
64
69
|
- **Video player**: Imperative video element (createElement, append to container ref, pass to videoPlayer); dispose + removeChild in cleanup; fall back to AdvancedVideo if init fails.
|
|
65
70
|
|
|
66
71
|
**If the user is not using Vite:** Use their bundler's client env prefix and access in the config file and everywhere you read env. Examples: Create React App → `REACT_APP_CLOUDINARY_CLOUD_NAME`, `process.env.REACT_APP_CLOUDINARY_CLOUD_NAME`; Next.js (client) → `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`. The rest (cld instance, widget options, video player) is the same.
|
|
@@ -141,7 +146,7 @@ cld.image('id').overlay(
|
|
|
141
146
|
|
|
142
147
|
// Image overlay (logo/image with resize)
|
|
143
148
|
cld.image('id').overlay(
|
|
144
|
-
source(image('logo').transformation(new Transformation().resize(scale().width(100)
|
|
149
|
+
source(image('logo').transformation(new Transformation().resize(scale().width(100))))
|
|
145
150
|
.position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20))
|
|
146
151
|
);
|
|
147
152
|
```
|
|
@@ -192,7 +197,7 @@ cld.image('id').overlay(
|
|
|
192
197
|
|
|
193
198
|
### Transformation Best Practices
|
|
194
199
|
- ✅ Format and quality must use separate `.delivery()` calls
|
|
195
|
-
- ✅ Always end with auto format/quality: `.delivery(format(auto())).delivery(quality(autoQuality()))`
|
|
200
|
+
- ✅ Always end with auto format/quality: `.delivery(format(auto())).delivery(quality(autoQuality()))` unless user specifies a particular format or quality
|
|
196
201
|
- ✅ Use `gravity(auto())` unless user specifies a focal point
|
|
197
202
|
- ✅ Same transformation syntax works for both images and videos
|
|
198
203
|
|
|
@@ -201,16 +206,16 @@ cld.image('id').overlay(
|
|
|
201
206
|
- ✅ Import plugins from `@cloudinary/react`
|
|
202
207
|
- ✅ Pass plugins as array: `plugins={[responsive(), lazyload(), placeholder()]}`
|
|
203
208
|
- ✅ Recommended plugin order:
|
|
204
|
-
1. `responsive()` - First
|
|
205
|
-
2. `
|
|
206
|
-
3. `
|
|
207
|
-
4. `
|
|
209
|
+
1. `responsive()` - First (handles breakpoints)
|
|
210
|
+
2. `placeholder()` - Second (shows placeholder while loading)
|
|
211
|
+
3. `lazyload()` - Third (delays loading until in viewport)
|
|
212
|
+
4. `accessibility()` - Last (if needed)
|
|
208
213
|
- ✅ Always add `width` and `height` attributes to prevent layout shift
|
|
209
214
|
- ✅ Example:
|
|
210
215
|
```tsx
|
|
211
216
|
<AdvancedImage
|
|
212
217
|
cldImg={img}
|
|
213
|
-
plugins={[responsive(),
|
|
218
|
+
plugins={[responsive(), placeholder({ mode: 'blur' }), lazyload()]}
|
|
214
219
|
width={800}
|
|
215
220
|
height={600}
|
|
216
221
|
/>
|
|
@@ -241,20 +246,27 @@ cld.image('id').overlay(
|
|
|
241
246
|
- ✅ **When the user asks for image overlays with text or logos**: Use `@cloudinary/url-gen` overlay APIs. Copy imports and patterns from the **"Import reference"** table and **"Canonical overlay block"** in these rules. Do not import `text` or `image` from `actions/overlay` — they are from **`qualifiers/source`**; only `source` is from `actions/overlay`.
|
|
242
247
|
- ✅ **Import** `source` from `actions/overlay`; **`text` and `image` from `qualifiers/source`**. Also: `Position` from `qualifiers/position`, `TextStyle` from `qualifiers/textStyle`, `compass` from `qualifiers/gravity`, `Transformation` from `transformation/Transformation`, `scale` from `actions/resize`.
|
|
243
248
|
- ✅ **compass()** takes **string** values, with **underscores**: `compass('center')`, `compass('south_east')`, `compass('north_west')`. ❌ WRONG: `compass(southEast)` or `'southEast'` (no camelCase).
|
|
244
|
-
- ✅ **Overlay image**: Use `new Transformation()` **inside** `.transformation()`: `image('logo').transformation(new Transformation().resize(scale().width(100)
|
|
249
|
+
- ✅ **Overlay image**: Use `new Transformation()` **inside** `.transformation()`: `image('logo').transformation(new Transformation().resize(scale().width(100)))`. ❌ WRONG: `image('logo').transformation().resize(...)` (`.transformation()` does not return a chainable object).
|
|
245
250
|
- ✅ **Text overlay**: `fontWeight` goes on **TextStyle**: `new TextStyle('Arial', 60).fontWeight('bold')`. `textColor` goes on the **text source** (chained after `text(...)`): `text('Hello', new TextStyle('Arial', 60)).textColor('white')`.
|
|
246
251
|
- ✅ **Position** is chained **after** `source(...)`, not inside: `source(image('logo').transformation(...)).position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20))`.
|
|
247
|
-
- ✅ **Image overlay pattern**: `baseImage.overlay(source(image('id').transformation(new Transformation().resize(scale().width(100)
|
|
252
|
+
- ✅ **Image overlay pattern**: `baseImage.overlay(source(image('id').transformation(new Transformation().resize(scale().width(100)))).position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20)))`. (Import `scale` from `@cloudinary/url-gen/actions/resize` if needed.)
|
|
248
253
|
- ✅ **Text overlay pattern**: `baseImage.overlay(source(text('Your Text', new TextStyle('Arial', 60).fontWeight('bold')).textColor('white')).position(new Position().gravity(compass('center'))))`.
|
|
249
254
|
- ✅ Docs: React Image Transformations and transformation reference for overlay syntax.
|
|
250
255
|
|
|
251
256
|
## Upload Widget Pattern
|
|
252
257
|
- ✅ Use component: `import { UploadWidget } from './cloudinary/UploadWidget'`
|
|
253
|
-
|
|
258
|
+
|
|
259
|
+
**Strict initialization pattern (always follow this exactly):**
|
|
260
|
+
1. ✅ **Script in `index.html`** (required):
|
|
254
261
|
```html
|
|
255
262
|
<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>
|
|
256
263
|
```
|
|
257
|
-
|
|
264
|
+
2. ✅ **Poll in useEffect until `createUploadWidget` is available** (required): Use `setInterval` (e.g. every 100ms) to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
|
|
265
|
+
3. ✅ **Add a timeout** (e.g. 10 seconds) to stop polling and show an error state if the script never loads. Clear both interval and timeout in cleanup and when ready.
|
|
266
|
+
4. ✅ **Create widget once**, store in a ref. Cleanup: clear interval, clear timeout, remove click listener.
|
|
267
|
+
|
|
268
|
+
❌ **Do NOT**: Check only `window.cloudinary` (the function may not be attached yet); do a single check in `onload` (unreliable timing); skip `index.html` and rely only on dynamic injection; poll forever without a timeout.
|
|
269
|
+
|
|
258
270
|
- ✅ Create unsigned upload preset in dashboard at `settings/upload/presets`
|
|
259
271
|
- ✅ Add to `.env`: `VITE_CLOUDINARY_UPLOAD_PRESET=your_preset_name`
|
|
260
272
|
- ✅ Handle callbacks:
|
|
@@ -343,7 +355,7 @@ res.json({ signature, timestamp: paramsToSign.timestamp, api_key: process.env.CL
|
|
|
343
355
|
```
|
|
344
356
|
|
|
345
357
|
- ❌ **Avoid `signatureEndpoint`** — it may not be called reliably by all widget versions. Prefer the `uploadSignature` function.
|
|
346
|
-
- ✅ Docs: [Upload widget — signed uploads](https://cloudinary.com/documentation/upload_widget#signed_uploads), [Upload assets in Next.js](https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial).
|
|
358
|
+
- ✅ Docs: [Upload widget — signed uploads](https://cloudinary.com/documentation/upload_widget.md#signed_uploads), [Upload assets in Next.js](https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial.md).
|
|
347
359
|
|
|
348
360
|
### Rules for secure uploads
|
|
349
361
|
- ✅ Use a **signed** upload preset (dashboard → Upload presets → Signed). Do not use an unsigned preset when the user wants secure uploads. **Default:** Accounts have a built-in signed preset `ml_default` — use it if the user hasn't created their own (they can delete `ml_default`, in which case they must create a signed preset in the dashboard).
|
|
@@ -400,7 +412,7 @@ res.json({ signature, timestamp: paramsToSign.timestamp, api_key: process.env.CL
|
|
|
400
412
|
muted
|
|
401
413
|
/>
|
|
402
414
|
```
|
|
403
|
-
- ✅ **Documentation**: https://cloudinary.com/documentation/react_video_transformations
|
|
415
|
+
- ✅ **Documentation**: https://cloudinary.com/documentation/react_video_transformations.md
|
|
404
416
|
|
|
405
417
|
### Cloudinary Video Player (The Player)
|
|
406
418
|
Use when the user asks for a **video player** (styled UI, controls, playlists). For just **displaying** a video, use AdvancedVideo instead.
|
|
@@ -413,6 +425,11 @@ Use when the user asks for a **video player** (styled UI, controls, playlists).
|
|
|
413
425
|
- **Cleanup**: Call `player.dispose()`, then **only if** `el.parentNode` exists call `el.parentNode.removeChild(el)` (avoids NotFoundError).
|
|
414
426
|
- **If init fails** (CSP, extensions, timing): render **AdvancedVideo** with the same publicId. Do not relax CSP in index.html or ask the user to disable extensions.
|
|
415
427
|
|
|
428
|
+
**Poster options**: Always include `posterOptions` for a predictable poster image with a fallback color:
|
|
429
|
+
- `transformation: { startOffset: '0' }` — use the first frame of the video as the poster (consistent and loads reliably)
|
|
430
|
+
- `posterColor: '#0f0f0f'` — if the poster image fails to load, shows a dark background instead of blank/broken
|
|
431
|
+
- These can be overridden via props (e.g. `posterOptions={{ transformation: { startOffset: '5' } }}` for a different frame)
|
|
432
|
+
|
|
416
433
|
**Example (copy this pattern):**
|
|
417
434
|
```tsx
|
|
418
435
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -423,7 +440,16 @@ useLayoutEffect(() => {
|
|
|
423
440
|
el.className = 'cld-video-player cld-fluid';
|
|
424
441
|
containerRef.current.appendChild(el);
|
|
425
442
|
try {
|
|
426
|
-
const player = videoPlayer(el, {
|
|
443
|
+
const player = videoPlayer(el, {
|
|
444
|
+
cloudName,
|
|
445
|
+
secure: true,
|
|
446
|
+
controls: true,
|
|
447
|
+
fluid: true,
|
|
448
|
+
posterOptions: {
|
|
449
|
+
transformation: { startOffset: '0' },
|
|
450
|
+
posterColor: '#0f0f0f',
|
|
451
|
+
},
|
|
452
|
+
});
|
|
427
453
|
player.source({ publicId: 'samples/elephants' });
|
|
428
454
|
playerRef.current = player;
|
|
429
455
|
} catch (err) { console.error(err); }
|
|
@@ -437,7 +463,7 @@ useLayoutEffect(() => {
|
|
|
437
463
|
}, [cloudName]);
|
|
438
464
|
return <div ref={containerRef} />;
|
|
439
465
|
```
|
|
440
|
-
Docs: https://cloudinary.com/documentation/cloudinary_video_player
|
|
466
|
+
Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
|
|
441
467
|
|
|
442
468
|
### When to Use Which?
|
|
443
469
|
- ✅ **Use AdvancedVideo** when: User wants to **display** or **show** a video (no full player). It just displays a video with transformations.
|
|
@@ -494,18 +520,28 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
|
|
|
494
520
|
- ✅ Access with type safety: `import.meta.env.VITE_CLOUDINARY_CLOUD_NAME`
|
|
495
521
|
|
|
496
522
|
### Type Guards and Safety
|
|
497
|
-
- ✅ Type guard for window.cloudinary:
|
|
523
|
+
- ✅ Type guard for window.cloudinary (check `createUploadWidget`, not just `cloudinary`):
|
|
498
524
|
```tsx
|
|
499
|
-
function
|
|
525
|
+
function isUploadWidgetReady(): boolean {
|
|
500
526
|
return typeof window !== 'undefined' &&
|
|
501
|
-
typeof window.cloudinary
|
|
527
|
+
typeof window.cloudinary?.createUploadWidget === 'function';
|
|
502
528
|
}
|
|
503
529
|
```
|
|
504
|
-
- ✅ Use type guards before accessing:
|
|
530
|
+
- ✅ Use type guards before accessing (but **always poll with timeout** in useEffect — don't rely on a single check):
|
|
505
531
|
```tsx
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
532
|
+
// In useEffect, poll until ready with timeout:
|
|
533
|
+
const interval = setInterval(() => {
|
|
534
|
+
if (isUploadWidgetReady()) {
|
|
535
|
+
clearInterval(interval);
|
|
536
|
+
clearTimeout(timeout);
|
|
537
|
+
window.cloudinary.createUploadWidget(...);
|
|
538
|
+
}
|
|
539
|
+
}, 100);
|
|
540
|
+
const timeout = setTimeout(() => {
|
|
541
|
+
clearInterval(interval);
|
|
542
|
+
console.error('Upload widget script failed to load');
|
|
543
|
+
}, 10000);
|
|
544
|
+
// Cleanup: clearInterval(interval); clearTimeout(timeout);
|
|
509
545
|
```
|
|
510
546
|
|
|
511
547
|
### Ref Typing Patterns
|
|
@@ -548,12 +584,12 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
|
|
|
548
584
|
```
|
|
549
585
|
|
|
550
586
|
## Best Practices
|
|
551
|
-
- ✅ Always use `fill()` resize for responsive images
|
|
552
|
-
- ✅ Always end transformations with `.delivery(format(auto())).delivery(quality(autoQuality()))`
|
|
587
|
+
- ✅ Always use `fill()` resize with automatic gravity for responsive images
|
|
588
|
+
- ✅ Always end transformations with `.delivery(format(auto())).delivery(quality(autoQuality()))` unless the user specifies a format or quality
|
|
553
589
|
- ✅ Use `placeholder()` and `lazyload()` plugins together
|
|
554
590
|
- ✅ Always add `width` and `height` attributes to `AdvancedImage`
|
|
555
591
|
- ✅ Store `public_id` from upload success, not full URL
|
|
556
|
-
- ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`
|
|
592
|
+
- ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`; always include `posterOptions` with `transformation: { startOffset: '0' }` and `posterColor: '#0f0f0f'` for reliable poster display
|
|
557
593
|
- ✅ Use TypeScript for better autocomplete and error catching
|
|
558
594
|
- ✅ Prefer `unknown` over `any` when types aren't available
|
|
559
595
|
- ✅ Use type guards for runtime type checking
|
|
@@ -656,16 +692,17 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
|
|
|
656
692
|
4. Consider using server-side upload for very large files
|
|
657
693
|
|
|
658
694
|
### Widget not opening
|
|
659
|
-
- ❌ Problem: Script not loaded or
|
|
695
|
+
- ❌ Problem: Script not loaded, or widget created before `createUploadWidget` was available
|
|
660
696
|
- ✅ Solution:
|
|
661
697
|
1. Ensure script is in `index.html`: `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>`
|
|
662
|
-
2.
|
|
698
|
+
2. In `useEffect`, **poll** with `setInterval` until `typeof window.cloudinary?.createUploadWidget === 'function'` — only then create the widget. Do **not** check only `window.cloudinary`.
|
|
663
699
|
3. Verify upload preset is set correctly
|
|
664
700
|
|
|
665
701
|
### "createUploadWidget is not a function"
|
|
666
|
-
- ❌ Problem: **Race condition** — the script
|
|
667
|
-
- ✅ **
|
|
668
|
-
-
|
|
702
|
+
- ❌ Problem: **Race condition** — the script loads **async**, so `window.cloudinary` can exist before `createUploadWidget` is attached. A single check (even in `onload`) is **not** reliable.
|
|
703
|
+
- ✅ **Always poll**: In `useEffect`, use `setInterval` to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
|
|
704
|
+
- ❌ **Do NOT**: Check only `window.cloudinary`; do a single check in `onload`; skip the script in `index.html`.
|
|
705
|
+
- ✅ See PATTERNS → Upload Widget Pattern and Project setup → Upload Widget for the strict pattern.
|
|
669
706
|
|
|
670
707
|
### Video player: "Invalid target for null#on" or React removeChild or NotFoundError
|
|
671
708
|
- ❌ Problem: Passing a React-managed `<video ref={...} />` to the player causes removeChild errors (the player mutates the DOM). Or container/ref not in DOM yet when init runs.
|
|
@@ -718,7 +755,7 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
|
|
|
718
755
|
|
|
719
756
|
### Cloudinary package install fails or "version doesn't exist"
|
|
720
757
|
- ❌ Problem: Agent pinned a Cloudinary package to a specific version (e.g. `cloudinary-video-player@1.2.3`) that doesn't exist on npm, or used a wrong package name.
|
|
721
|
-
- ✅ **Install latest**: Use `npm install <package>` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/
|
|
758
|
+
- ✅ **Install latest**: Use `npm install <package>` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/url-gen`, `cloudinary-video-player`, `cloudinary`. See PATTERNS → "Installing Cloudinary packages".
|
|
722
759
|
|
|
723
760
|
### Confusion between AdvancedVideo and Video Player
|
|
724
761
|
- **AdvancedVideo** = for **displaying** a video (not a full player). **Cloudinary Video Player** = the **player** (styled UI, controls, playlists, etc.).
|
|
@@ -742,6 +779,11 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
|
|
|
742
779
|
### Video player: "source is not a function" or video not playing
|
|
743
780
|
- **player.source()** takes an **object**: `player.source({ publicId: 'samples/elephants' })`, not a string. Use named import: `import { videoPlayer } from 'cloudinary-video-player'`. See PATTERNS → Cloudinary Video Player (The Player).
|
|
744
781
|
|
|
782
|
+
### Video player: poster image missing, wrong frame, or broken
|
|
783
|
+
- ❌ Problem: Video player shows no poster, wrong poster frame, or blank area before video loads.
|
|
784
|
+
- ✅ **Always include `posterOptions`** in the player config: `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }`. This uses the first frame as the poster (reliable) and provides a dark fallback color if the poster fails to load.
|
|
785
|
+
- ✅ **Override if needed**: Pass different values via props, e.g. `startOffset: '5'` for a frame 5 seconds in, or a different `posterColor` for your design.
|
|
786
|
+
|
|
745
787
|
### Overlay: "Cannot read properties of undefined" or overlay not showing
|
|
746
788
|
- ❌ Problem: Wrong overlay API usage (Overlay.source, compass constants, .transformation().resize, fontWeight on wrong object).
|
|
747
789
|
- ✅ Import `source` directly from `@cloudinary/url-gen/actions/overlay` (not `Overlay.source`). Use **string** values for compass: `compass('south_east')` (underscores, not camelCase). Use `new Transformation()` inside `.transformation()`. Put `fontWeight` on **TextStyle**; put `textColor` on the **text source**. See PATTERNS → Image Overlays (text or logos).
|
|
@@ -815,8 +857,8 @@ When something isn't working, check:
|
|
|
815
857
|
- [ ] Format/quality use separate `.delivery()` calls
|
|
816
858
|
- [ ] Plugins are in array format
|
|
817
859
|
- [ ] Upload widget script is loaded in `index.html`
|
|
818
|
-
- [ ] **"createUploadWidget is not a function"?** →
|
|
819
|
-
- [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
|
|
860
|
+
- [ ] **"createUploadWidget is not a function"?** → In useEffect, **poll** with setInterval until `typeof window.cloudinary?.createUploadWidget === 'function'`. Do NOT check only `window.cloudinary`; do NOT rely on a single onload check
|
|
861
|
+
- [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); include `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }` for reliable poster; player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
|
|
820
862
|
- [ ] **Upload fails (unsigned)?** → Is `VITE_CLOUDINARY_UPLOAD_PRESET` set? Preset exists and is Unsigned in dashboard?
|
|
821
863
|
- [ ] **Upload default?** → Default to **unsigned** uploads (cloudName + uploadPreset); use signed only when the user explicitly asks for secure/signed uploads (signed requires a running backend)
|
|
822
864
|
- [ ] **Secure uploads?** → Use `uploadSignature` as function (not `signatureEndpoint`); fetch `api_key` from server first; include `uploadPreset` in widget config; server includes `upload_preset` in signed params; use Cloudinary Node SDK v2 on server; never expose or commit API secret
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
10
|
<div id="root"></div>
|
|
11
|
+
<!-- Cloudinary upload widget - load early so it's ready before React; avoids races with other components -->
|
|
12
|
+
<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>
|
|
11
13
|
<script type="module" src="/src/main.tsx"></script>
|
|
12
14
|
</body>
|
|
13
15
|
</html>
|
|
@@ -4,14 +4,16 @@ import { fill } from '@cloudinary/url-gen/actions/resize';
|
|
|
4
4
|
import { format, quality } from '@cloudinary/url-gen/actions/delivery';
|
|
5
5
|
import { auto } from '@cloudinary/url-gen/qualifiers/format';
|
|
6
6
|
import { auto as autoQuality } from '@cloudinary/url-gen/qualifiers/quality';
|
|
7
|
+
import { autoGravity } from '@cloudinary/url-gen/qualifiers/gravity';
|
|
7
8
|
import { cld } from './cloudinary/config';
|
|
8
9
|
import { UploadWidget } from './cloudinary/UploadWidget';
|
|
10
|
+
import type { CloudinaryUploadResult } from './cloudinary/UploadWidget';
|
|
9
11
|
import './App.css';
|
|
10
12
|
|
|
11
13
|
function App() {
|
|
12
14
|
const [uploadedImageId, setUploadedImageId] = useState<string | null>(null);
|
|
13
15
|
|
|
14
|
-
const handleUploadSuccess = (result:
|
|
16
|
+
const handleUploadSuccess = (result: CloudinaryUploadResult) => {
|
|
15
17
|
console.log('Upload successful:', result);
|
|
16
18
|
setUploadedImageId(result.public_id);
|
|
17
19
|
};
|
|
@@ -26,7 +28,7 @@ function App() {
|
|
|
26
28
|
|
|
27
29
|
const displayImage = cld
|
|
28
30
|
.image(imageId)
|
|
29
|
-
.resize(fill().width(600).height(400))
|
|
31
|
+
.resize(fill().width(600).height(400).gravity(autoGravity()))
|
|
30
32
|
.delivery(format(auto()))
|
|
31
33
|
.delivery(quality(autoQuality()));
|
|
32
34
|
|
|
@@ -1,16 +1,42 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { uploadPreset } from './config';
|
|
3
3
|
|
|
4
|
+
export interface CloudinaryUploadResult {
|
|
5
|
+
public_id: string;
|
|
6
|
+
secure_url: string;
|
|
7
|
+
url: string;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
format: string;
|
|
11
|
+
resource_type: string;
|
|
12
|
+
bytes: number;
|
|
13
|
+
created_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
interface UploadWidgetProps {
|
|
5
|
-
onUploadSuccess?: (result:
|
|
17
|
+
onUploadSuccess?: (result: CloudinaryUploadResult) => void;
|
|
6
18
|
onUploadError?: (error: Error) => void;
|
|
7
19
|
buttonText?: string;
|
|
8
20
|
className?: string;
|
|
9
21
|
}
|
|
10
22
|
|
|
23
|
+
interface CloudinaryWidgetResult {
|
|
24
|
+
event: string;
|
|
25
|
+
info: CloudinaryUploadResult;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CloudinaryWidgetError {
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
declare global {
|
|
12
33
|
interface Window {
|
|
13
|
-
cloudinary
|
|
34
|
+
cloudinary?: {
|
|
35
|
+
createUploadWidget: (
|
|
36
|
+
config: Record<string, unknown>,
|
|
37
|
+
callback: (error: CloudinaryWidgetError | null, result: CloudinaryWidgetResult | null) => void
|
|
38
|
+
) => { open: () => void };
|
|
39
|
+
};
|
|
14
40
|
}
|
|
15
41
|
}
|
|
16
42
|
|
|
@@ -20,13 +46,17 @@ export function UploadWidget({
|
|
|
20
46
|
buttonText = 'Upload Image',
|
|
21
47
|
className = '',
|
|
22
48
|
}: UploadWidgetProps) {
|
|
23
|
-
const widgetRef = useRef<
|
|
24
|
-
const
|
|
25
|
-
const
|
|
49
|
+
const widgetRef = useRef<{ open: () => void } | null>(null);
|
|
50
|
+
const [isReady, setIsReady] = useState(false);
|
|
51
|
+
const [scriptError, setScriptError] = useState(false);
|
|
26
52
|
|
|
27
53
|
useEffect(() => {
|
|
54
|
+
let poll: ReturnType<typeof setInterval> | null = null;
|
|
55
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
56
|
+
let mounted = true;
|
|
57
|
+
|
|
28
58
|
function initializeWidget() {
|
|
29
|
-
if (typeof window.cloudinary?.createUploadWidget !== 'function'
|
|
59
|
+
if (!mounted || typeof window.cloudinary?.createUploadWidget !== 'function') return;
|
|
30
60
|
|
|
31
61
|
if (!uploadPreset) {
|
|
32
62
|
console.warn(
|
|
@@ -42,7 +72,7 @@ export function UploadWidget({
|
|
|
42
72
|
sources: ['local', 'camera', 'url'],
|
|
43
73
|
multiple: false,
|
|
44
74
|
},
|
|
45
|
-
(error:
|
|
75
|
+
(error: CloudinaryWidgetError | null, result: CloudinaryWidgetResult | null) => {
|
|
46
76
|
if (error) {
|
|
47
77
|
console.error('Upload error:', error);
|
|
48
78
|
onUploadError?.(new Error(error.message || 'Upload failed'));
|
|
@@ -56,73 +86,88 @@ export function UploadWidget({
|
|
|
56
86
|
}
|
|
57
87
|
);
|
|
58
88
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
89
|
+
setIsReady(true);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isWidgetReady(): boolean {
|
|
93
|
+
return typeof window.cloudinary?.createUploadWidget === 'function';
|
|
62
94
|
}
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
// Poll until createUploadWidget is available
|
|
97
|
+
// Script should be in index.html, but poll handles any load timing
|
|
98
|
+
poll = setInterval(() => {
|
|
99
|
+
if (isWidgetReady()) {
|
|
100
|
+
if (poll) clearInterval(poll);
|
|
101
|
+
if (timeout) clearTimeout(timeout);
|
|
66
102
|
initializeWidget();
|
|
67
|
-
return true;
|
|
68
103
|
}
|
|
69
|
-
|
|
70
|
-
}
|
|
104
|
+
}, 100);
|
|
71
105
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return () => {
|
|
87
|
-
clearInterval(poll);
|
|
88
|
-
clearTimeout(timeout);
|
|
89
|
-
if (buttonRef.current && clickHandlerRef.current) {
|
|
90
|
-
buttonRef.current.removeEventListener('click', clickHandlerRef.current);
|
|
91
|
-
}
|
|
92
|
-
};
|
|
106
|
+
// Timeout after 10 seconds
|
|
107
|
+
timeout = setTimeout(() => {
|
|
108
|
+
if (poll) clearInterval(poll);
|
|
109
|
+
if (mounted && !isWidgetReady()) {
|
|
110
|
+
console.error('Upload widget script failed to load within 10 seconds');
|
|
111
|
+
setScriptError(true);
|
|
112
|
+
}
|
|
113
|
+
}, 10000);
|
|
114
|
+
|
|
115
|
+
// Check immediately in case script is already loaded
|
|
116
|
+
if (isWidgetReady()) {
|
|
117
|
+
if (poll) clearInterval(poll);
|
|
118
|
+
if (timeout) clearTimeout(timeout);
|
|
119
|
+
initializeWidget();
|
|
93
120
|
}
|
|
94
121
|
|
|
95
122
|
return () => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
mounted = false;
|
|
124
|
+
if (poll) clearInterval(poll);
|
|
125
|
+
if (timeout) clearTimeout(timeout);
|
|
99
126
|
};
|
|
100
127
|
}, [onUploadSuccess, onUploadError]);
|
|
101
128
|
|
|
129
|
+
const handleClick = () => {
|
|
130
|
+
if (widgetRef.current) {
|
|
131
|
+
widgetRef.current.open();
|
|
132
|
+
} else if (!scriptError) {
|
|
133
|
+
console.warn('Upload widget is still loading, please try again.');
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (scriptError) {
|
|
138
|
+
return (
|
|
139
|
+
<div style={{ color: '#dc2626', fontSize: '0.875rem' }}>
|
|
140
|
+
Upload widget failed to load. Please refresh the page or check your network connection.
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
102
145
|
return (
|
|
103
146
|
<button
|
|
104
|
-
ref={buttonRef}
|
|
105
147
|
type="button"
|
|
148
|
+
onClick={handleClick}
|
|
149
|
+
disabled={!isReady}
|
|
106
150
|
className={className}
|
|
107
151
|
style={{
|
|
108
152
|
padding: '0.75rem 1.5rem',
|
|
109
153
|
fontSize: '1rem',
|
|
110
154
|
fontWeight: 500,
|
|
111
155
|
color: 'white',
|
|
112
|
-
backgroundColor: '#6366f1',
|
|
156
|
+
backgroundColor: isReady ? '#6366f1' : '#9ca3af',
|
|
113
157
|
border: 'none',
|
|
114
158
|
borderRadius: '0.5rem',
|
|
115
|
-
cursor: 'pointer',
|
|
159
|
+
cursor: isReady ? 'pointer' : 'wait',
|
|
116
160
|
transition: 'background-color 0.2s',
|
|
161
|
+
opacity: isReady ? 1 : 0.7,
|
|
117
162
|
}}
|
|
118
163
|
onMouseEnter={(e) => {
|
|
119
|
-
e.currentTarget.style.backgroundColor = '#4f46e5';
|
|
164
|
+
if (isReady) e.currentTarget.style.backgroundColor = '#4f46e5';
|
|
120
165
|
}}
|
|
121
166
|
onMouseLeave={(e) => {
|
|
122
|
-
e.currentTarget.style.backgroundColor = '#6366f1';
|
|
167
|
+
if (isReady) e.currentTarget.style.backgroundColor = '#6366f1';
|
|
123
168
|
}}
|
|
124
169
|
>
|
|
125
|
-
{buttonText}
|
|
170
|
+
{isReady ? buttonText : 'Loading...'}
|
|
126
171
|
</button>
|
|
127
172
|
);
|
|
128
173
|
}
|