@inoo-ch/payload-image-optimizer 1.6.1 → 1.8.0

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/AGENT_DOCS.md CHANGED
@@ -60,6 +60,7 @@ imageOptimizer({
60
60
  generateThumbHash: true, // generate blur placeholders
61
61
  replaceOriginal: true, // convert main file to primary format
62
62
  clientOptimization: true, // pre-resize in browser before upload
63
+ uniqueFileNames: false, // replace filenames with UUIDs
63
64
  disabled: false, // keep fields but skip all processing
64
65
  })
65
66
  ```
@@ -70,6 +71,7 @@ imageOptimizer({
70
71
  | `formats` | `{ format: 'webp' \| 'avif', quality: number }[]` | `[{ format: 'webp', quality: 80 }]` | Output formats to generate. |
71
72
  | `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions (fit inside, no upscaling). |
72
73
  | `stripMetadata` | `boolean` | `true` | Strip EXIF, ICC, XMP metadata. |
74
+ | `uniqueFileNames` | `boolean` | `false` | Replace filenames with UUIDs. Prevents Vercel Blob collisions and hides original filenames. |
73
75
  | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholder. |
74
76
  | `replaceOriginal` | `boolean` | `true` | Replace the original file with the primary format. |
75
77
  | `clientOptimization` | `boolean` | `true` | Pre-resize images in browser via Canvas API before upload. Reduces upload size 90%+ for large images. |
@@ -97,6 +99,7 @@ collections: {
97
99
  When an image is uploaded to an optimized collection:
98
100
 
99
101
  1. **`beforeChange` hook** (in-memory processing):
102
+ - If `uniqueFileNames: true`: renames file to UUID (e.g., `photo.jpg` → `a1b2c3d4.jpg`)
100
103
  - Auto-rotates based on EXIF orientation
101
104
  - Resizes to fit within `maxDimensions`
102
105
  - Strips metadata (if enabled)
@@ -119,6 +122,7 @@ When an image is uploaded to an optimized collection:
119
122
  | File | Naming Pattern | Example |
120
123
  |------|---------------|---------|
121
124
  | Main file (replaceOriginal) | `{name}.{primaryFormat}` | `photo.webp` |
125
+ | Main file (uniqueFileNames) | `{uuid}.{primaryFormat}` | `a1b2c3d4-5e6f-7890.webp` |
122
126
  | Variant files | `{name}-optimized.{format}` | `photo-optimized.avif` |
123
127
 
124
128
  ### Format Behavior
@@ -462,6 +466,30 @@ vercelBlobStorage({
462
466
  })
463
467
  ```
464
468
 
469
+ ### "This blob already exists" error
470
+
471
+ When `replaceOriginal: true` (default), the plugin changes filenames (e.g., `photo.jpg` → `photo.webp`). If a blob with that name already exists, Vercel Blob throws an error because `@payloadcms/storage-vercel-blob` does not pass `allowOverwrite` to the Vercel Blob SDK.
472
+
473
+ **Fix (recommended):** Enable `uniqueFileNames` in the plugin config — replaces filenames with UUIDs before the storage adapter sees them. This fixes both initial uploads AND regeneration (the regeneration task also generates a new UUID for cloud storage re-uploads):
474
+
475
+ ```ts
476
+ imageOptimizer({
477
+ collections: { media: true },
478
+ uniqueFileNames: true, // photo.jpg → a1b2c3d4-5e6f-7890-abcd-ef1234567890.webp
479
+ })
480
+ ```
481
+
482
+ **Alternative:** Set `addRandomSuffix: true` on the storage adapter (only fixes initial uploads, not regeneration):
483
+
484
+ ```ts
485
+ vercelBlobStorage({
486
+ collections: { media: true },
487
+ token: process.env.BLOB_READ_WRITE_TOKEN,
488
+ clientUploads: true,
489
+ addRandomSuffix: true,
490
+ })
491
+ ```
492
+
465
493
  ## Full Example
466
494
 
467
495
  ```ts
package/README.md CHANGED
@@ -102,6 +102,7 @@ imageOptimizer({
102
102
  | `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions. Images are resized to fit within these bounds. |
103
103
  | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholders for instant image previews. |
104
104
  | `stripMetadata` | `boolean` | `true` | Remove EXIF and other metadata from images. |
105
+ | `uniqueFileNames` | `boolean` | `false` | Replace filenames with UUIDs (e.g., `photo.jpg` → `a1b2c3d4.webp`). Prevents Vercel Blob "already exists" errors on uploads and regeneration. |
105
106
  | `clientOptimization` | `boolean` | `true` | Pre-resize images in the browser before upload using Canvas API. Reduces upload size by up to 90% for large images. |
106
107
  | `disabled` | `boolean` | `false` | Disable optimization while keeping schema fields intact. |
107
108
 
@@ -183,6 +184,32 @@ vercelBlobStorage({
183
184
 
184
185
  With `clientUploads: true`, files upload directly from the browser to Vercel Blob (up to 5TB) and the server only handles the small JSON metadata payload. This eliminates body size limit errors regardless of file size.
185
186
 
187
+ #### "This blob already exists" error
188
+
189
+ When `replaceOriginal: true` (default), the plugin changes filenames during upload (e.g., `photo.jpg` → `photo.webp`). If a blob with that name already exists, Vercel Blob throws an error because `@payloadcms/storage-vercel-blob` does not pass [`allowOverwrite`](https://vercel.com/docs/vercel-blob#overwriting-blobs) to the Vercel Blob SDK.
190
+
191
+ **Fix:** Enable `uniqueFileNames` in the plugin config — replaces original filenames with UUIDs before the storage adapter sees them:
192
+
193
+ ```ts
194
+ imageOptimizer({
195
+ collections: { media: true },
196
+ uniqueFileNames: true, // photo.jpg → a1b2c3d4-5e6f-7890-abcd-ef1234567890.webp
197
+ })
198
+ ```
199
+
200
+ This prevents collisions on both initial uploads and bulk regeneration (the regeneration task also generates a new UUID for cloud storage re-uploads). Payload stores the full URL in the database, so UUID filenames are transparent to your application.
201
+
202
+ **Alternative:** If you prefer to keep original filenames, set `addRandomSuffix: true` on the storage adapter instead:
203
+
204
+ ```ts
205
+ vercelBlobStorage({
206
+ collections: { media: true },
207
+ token: process.env.BLOB_READ_WRITE_TOKEN,
208
+ clientUploads: true,
209
+ addRandomSuffix: true,
210
+ })
211
+ ```
212
+
186
213
  ## How It Differs from Payload's Default Image Handling
187
214
 
188
215
  Payload CMS ships with [sharp](https://sharp.pixelplumbing.com/) built-in and can resize images and generate sizes on upload. This plugin **does not double-process your images** — it intercepts the raw upload in a `beforeChange` hook *before* Payload's own sharp pipeline runs, and writes the optimized buffer back to `req.file.data`. When Payload's built-in `uploadFiles` step kicks in to generate your configured sizes, it works from the already-optimized file, not the raw original.
@@ -1,12 +1,15 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import React, { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { useSelection } from '@payloadcms/ui';
4
5
  const POLL_INTERVAL_MS = 2000;
5
6
  // With sequential processing each image takes ~4-5s, so no progress for 30s
6
7
  // (15 polls) strongly suggests a real stall rather than slow processing.
7
8
  const STALL_THRESHOLD = 15;
8
9
  const SESSION_KEY = 'imageOptimizer_running';
9
10
  export const RegenerationButton = ()=>{
11
+ const { count: selectionCount, getSelectedIds } = useSelection();
12
+ const hasSelection = selectionCount > 0;
10
13
  const [isRunning, setIsRunning] = useState(false);
11
14
  const [progress, setProgress] = useState(null);
12
15
  const [queued, setQueued] = useState(null);
@@ -169,15 +172,19 @@ export const RegenerationButton = ()=>{
169
172
  stallCount: 0
170
173
  };
171
174
  try {
175
+ const requestBody = {
176
+ collectionSlug,
177
+ force
178
+ };
179
+ if (hasSelection) {
180
+ requestBody.docIds = getSelectedIds().map(String);
181
+ }
172
182
  const res = await fetch('/api/image-optimizer/regenerate', {
173
183
  method: 'POST',
174
184
  headers: {
175
185
  'Content-Type': 'application/json'
176
186
  },
177
- body: JSON.stringify({
178
- collectionSlug,
179
- force
180
- })
187
+ body: JSON.stringify(requestBody)
181
188
  });
182
189
  if (!res.ok) {
183
190
  const data = await res.json();
@@ -233,7 +240,7 @@ export const RegenerationButton = ()=>{
233
240
  fontWeight: 500,
234
241
  cursor: isRunning ? 'not-allowed' : 'pointer'
235
242
  },
236
- children: isRunning ? 'Processing all images...' : 'Regenerate All Images'
243
+ children: isRunning ? 'Processing images...' : hasSelection ? `Regenerate ${selectionCount} Selected` : 'Regenerate All Images'
237
244
  }),
238
245
  confirming && stats && /*#__PURE__*/ _jsxs("div", {
239
246
  style: {
@@ -247,7 +254,7 @@ export const RegenerationButton = ()=>{
247
254
  fontSize: '13px',
248
255
  color: '#374151'
249
256
  },
250
- children: force ? `Re-process all ${stats.total} images across the entire collection?` : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`
257
+ children: hasSelection ? `Regenerate ${selectionCount} selected image${selectionCount !== 1 ? 's' : ''}?` : force ? `Re-process all ${stats.total} images across the entire collection?` : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`
251
258
  }),
252
259
  /*#__PURE__*/ _jsx("button", {
253
260
  onClick: handleConfirm,
@@ -313,7 +320,7 @@ export const RegenerationButton = ()=>{
313
320
  queued,
314
321
  " image",
315
322
  queued !== 1 ? 's' : '',
316
- " for processing across the entire collection"
323
+ " for processing"
317
324
  ]
318
325
  }),
319
326
  queued === 0 && !isRunning && !stalled && !confirming && /*#__PURE__*/ _jsx("span", {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/RegenerationButton.tsx"],"sourcesContent":["'use client'\n\nimport React, { useState, useEffect, useCallback, useRef } from 'react'\n\ntype RegenerationProgress = {\n total: number\n complete: number\n errored: number\n pending: number\n}\n\nconst POLL_INTERVAL_MS = 2000\n// With sequential processing each image takes ~4-5s, so no progress for 30s\n// (15 polls) strongly suggests a real stall rather than slow processing.\nconst STALL_THRESHOLD = 15\nconst SESSION_KEY = 'imageOptimizer_running'\n\nexport const RegenerationButton: React.FC = () => {\n const [isRunning, setIsRunning] = useState(false)\n const [progress, setProgress] = useState<RegenerationProgress | null>(null)\n const [queued, setQueued] = useState<number | null>(null)\n const [force, setForce] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [stalled, setStalled] = useState(false)\n const [collectionSlug, setCollectionSlug] = useState<string | null>(null)\n const [stats, setStats] = useState<RegenerationProgress | null>(null)\n const [confirming, setConfirming] = useState(false)\n const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n const stallRef = useRef({ lastProcessed: 0, stallCount: 0 })\n const prevIsRunningRef = useRef(false)\n\n // Extract collection slug from URL after mount to avoid hydration mismatch\n useEffect(() => {\n const slug = window.location.pathname.split('/collections/')[1]?.split('/')[0] ?? null\n setCollectionSlug(slug)\n }, [])\n\n // Fetch optimization stats (independent of regeneration)\n const fetchStats = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setStats(data)\n }\n } catch {\n // ignore stats fetch errors\n }\n }, [collectionSlug])\n\n const stopPolling = useCallback(() => {\n if (intervalRef.current) {\n clearInterval(intervalRef.current)\n intervalRef.current = null\n }\n }, [])\n\n const startPolling = useCallback(\n (pollFn: () => void) => {\n // Prevent duplicate intervals\n stopPolling()\n intervalRef.current = setInterval(pollFn, POLL_INTERVAL_MS)\n },\n [stopPolling],\n )\n\n const pollProgress = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setProgress(data)\n\n // Stop polling when no more pending\n if (data.pending <= 0) {\n setIsRunning(false)\n setStalled(false)\n stopPolling()\n sessionStorage.removeItem(SESSION_KEY)\n return\n }\n\n // Stall detection — warn but keep polling so we detect when jobs resume\n const processed = data.complete + data.errored\n if (processed === stallRef.current.lastProcessed) {\n stallRef.current.stallCount += 1\n } else {\n stallRef.current.stallCount = 0\n stallRef.current.lastProcessed = processed\n // Clear stall warning when progress resumes\n setStalled(false)\n }\n\n if (stallRef.current.stallCount >= STALL_THRESHOLD) {\n setStalled(true)\n // Keep polling — jobs may still be running server-side\n }\n }\n } catch {\n // ignore polling errors\n }\n }, [collectionSlug, stopPolling])\n\n // On mount: fetch stats for the counter display. If the user previously\n // triggered regeneration (sessionStorage flag) and there are still pending\n // images, resume polling so the UI reconnects after page navigation.\n useEffect(() => {\n if (!collectionSlug) return\n let cancelled = false\n const loadStats = async () => {\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (!res.ok || cancelled) return\n const data: RegenerationProgress = await res.json()\n setStats(data)\n\n // Resume polling only if the user triggered regeneration in this session\n const wasRunning = sessionStorage.getItem(SESSION_KEY) === collectionSlug\n if (wasRunning && data.pending > 0) {\n setProgress(data)\n setIsRunning(true)\n setStalled(false)\n stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }\n startPolling(pollProgress)\n } else if (wasRunning && data.pending <= 0) {\n // Jobs finished while we were away — clear the flag\n sessionStorage.removeItem(SESSION_KEY)\n }\n } catch {\n // ignore\n }\n }\n loadStats()\n return () => {\n cancelled = true\n stopPolling()\n }\n }, [collectionSlug, pollProgress, startPolling, stopPolling])\n\n // Refresh stats when regeneration finishes (isRunning transitions from true to false)\n useEffect(() => {\n if (prevIsRunningRef.current && !isRunning) {\n fetchStats()\n }\n prevIsRunningRef.current = isRunning\n }, [isRunning, fetchStats])\n\n // Phase 1: Show confirmation with counts\n const handlePreflight = async () => {\n if (!collectionSlug) return\n setError(null)\n // Refresh stats to get the latest counts before confirming\n await fetchStats()\n setConfirming(true)\n }\n\n const handleCancel = () => {\n setConfirming(false)\n }\n\n // Phase 2: Actually start regeneration (after user confirms)\n const handleConfirm = async () => {\n if (!collectionSlug) return\n setConfirming(false)\n setError(null)\n setStalled(false)\n setIsRunning(true)\n setQueued(null)\n setProgress(null)\n stallRef.current = { lastProcessed: 0, stallCount: 0 }\n\n try {\n const res = await fetch('/api/image-optimizer/regenerate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ collectionSlug, force }),\n })\n\n if (!res.ok) {\n const data = await res.json()\n throw new Error(data.error || 'Failed to start regeneration')\n }\n\n const data = await res.json()\n setQueued(data.queued)\n\n if (data.queued === 0) {\n setIsRunning(false)\n return\n }\n\n // Persist running state so we can resume after page navigation\n sessionStorage.setItem(SESSION_KEY, collectionSlug)\n // Start polling\n startPolling(pollProgress)\n } catch (err) {\n setError(err instanceof Error ? err.message : String(err))\n setIsRunning(false)\n }\n }\n\n // Cleanup interval on unmount\n useEffect(() => {\n return () => stopPolling()\n }, [stopPolling])\n\n if (!collectionSlug) return null\n\n const progressPercent =\n progress && progress.total > 0\n ? Math.round(((progress.complete + progress.errored) / progress.total) * 100)\n : 0\n\n const showProgressBar = (isRunning && progress) || (stalled && progress)\n\n // Stats computations\n const statsPercent =\n stats && stats.total > 0\n ? Math.round((stats.complete / stats.total) * 100)\n : 0\n const allOptimized = stats && stats.total > 0 && stats.complete === stats.total\n\n return (\n <div\n style={{\n padding: '16px 24px',\n borderBottom: '1px solid #e5e7eb',\n display: 'flex',\n alignItems: 'center',\n gap: '16px',\n flexWrap: 'wrap',\n }}\n >\n {!confirming && (\n <button\n onClick={handlePreflight}\n disabled={isRunning}\n style={{\n backgroundColor: isRunning ? '#9ca3af' : '#4f46e5',\n color: '#fff',\n border: 'none',\n borderRadius: '6px',\n padding: '8px 16px',\n fontSize: '14px',\n fontWeight: 500,\n cursor: isRunning ? 'not-allowed' : 'pointer',\n }}\n >\n {isRunning ? 'Processing all images...' : 'Regenerate All Images'}\n </button>\n )}\n\n {confirming && stats && (\n <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>\n <span style={{ fontSize: '13px', color: '#374151' }}>\n {force\n ? `Re-process all ${stats.total} images across the entire collection?`\n : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`}\n </span>\n <button\n onClick={handleConfirm}\n style={{\n backgroundColor: '#4f46e5',\n color: '#fff',\n border: 'none',\n borderRadius: '6px',\n padding: '6px 14px',\n fontSize: '13px',\n fontWeight: 500,\n cursor: 'pointer',\n }}\n >\n Confirm\n </button>\n <button\n onClick={handleCancel}\n style={{\n backgroundColor: 'transparent',\n color: '#6b7280',\n border: '1px solid #d1d5db',\n borderRadius: '6px',\n padding: '6px 14px',\n fontSize: '13px',\n fontWeight: 500,\n cursor: 'pointer',\n }}\n >\n Cancel\n </button>\n </div>\n )}\n\n {!confirming && (\n <label\n style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px' }}\n >\n <input\n type=\"checkbox\"\n checked={force}\n onChange={(e) => setForce(e.target.checked)}\n disabled={isRunning}\n />\n Force re-process all\n </label>\n )}\n\n {error && (\n <span style={{ color: '#ef4444', fontSize: '13px' }}>{error}</span>\n )}\n\n {queued !== null && queued > 0 && isRunning && !confirming && (\n <span style={{ color: '#4f46e5', fontSize: '13px' }}>\n Queued {queued} image{queued !== 1 ? 's' : ''} for processing across the entire collection\n </span>\n )}\n\n {queued === 0 && !isRunning && !stalled && !confirming && (\n <span style={{ color: '#10b981', fontSize: '13px' }}>\n All images already optimized.\n </span>\n )}\n\n {stalled && progress && (\n <span style={{ color: '#f59e0b', fontSize: '13px' }}>\n Processing appears slow — {progress.pending} image{progress.pending !== 1 ? 's' : ''} still pending.\n Jobs may still be running server-side.\n </span>\n )}\n\n {showProgressBar && (\n <div style={{ flex: 1, minWidth: '200px' }}>\n <div\n style={{\n display: 'flex',\n justifyContent: 'space-between',\n fontSize: '12px',\n marginBottom: '4px',\n }}\n >\n <span>\n {progress.complete} / {progress.total} complete\n </span>\n {progress.errored > 0 && (\n <span style={{ color: '#ef4444' }}>{progress.errored} errors</span>\n )}\n <span>{progressPercent}%</span>\n </div>\n <div\n style={{\n height: '6px',\n backgroundColor: '#e5e7eb',\n borderRadius: '3px',\n overflow: 'hidden',\n display: 'flex',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.complete / progress.total) * 100) : 0}%`,\n backgroundColor: '#10b981',\n transition: 'width 0.3s ease',\n }}\n />\n {progress.errored > 0 && (\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.errored / progress.total) * 100) : 0}%`,\n backgroundColor: '#ef4444',\n transition: 'width 0.3s ease',\n }}\n />\n )}\n </div>\n </div>\n )}\n\n {!isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && !confirming && (\n <span style={{ fontSize: '13px' }}>\n <span style={{ color: progress.errored > 0 ? '#f59e0b' : '#10b981' }}>\n Done! {progress.complete}/{progress.total} optimized (across entire collection).\n </span>\n {progress.errored > 0 && (\n <span style={{ color: '#ef4444' }}>\n {' '}{progress.errored} failed.\n </span>\n )}\n </span>\n )}\n\n {/* Persistent optimization stats — always visible when not actively regenerating */}\n {!isRunning && !stalled && stats && stats.total > 0 && (\n <div\n style={{\n marginLeft: 'auto',\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-end',\n gap: '4px',\n minWidth: '180px',\n }}\n >\n <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>\n {allOptimized ? (\n <span style={{ color: '#10b981' }}>\n &#10003; All {stats.total} images optimized\n </span>\n ) : (\n <>\n <span style={{ color: '#6b7280' }}>\n {stats.complete}/{stats.total} optimized\n </span>\n {stats.errored > 0 && (\n <>\n <span style={{ color: '#d1d5db' }}>&middot;</span>\n <span style={{ color: '#ef4444' }}>{stats.errored} errors</span>\n </>\n )}\n </>\n )}\n </div>\n {!allOptimized && (\n <div\n style={{\n width: '100%',\n height: '3px',\n backgroundColor: '#e5e7eb',\n borderRadius: '2px',\n overflow: 'hidden',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${statsPercent}%`,\n backgroundColor: stats.errored > 0 ? '#f59e0b' : '#10b981',\n borderRadius: '2px',\n transition: 'width 0.3s ease',\n }}\n />\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":["React","useState","useEffect","useCallback","useRef","POLL_INTERVAL_MS","STALL_THRESHOLD","SESSION_KEY","RegenerationButton","isRunning","setIsRunning","progress","setProgress","queued","setQueued","force","setForce","error","setError","stalled","setStalled","collectionSlug","setCollectionSlug","stats","setStats","confirming","setConfirming","intervalRef","stallRef","lastProcessed","stallCount","prevIsRunningRef","slug","window","location","pathname","split","fetchStats","res","fetch","ok","data","json","stopPolling","current","clearInterval","startPolling","pollFn","setInterval","pollProgress","pending","sessionStorage","removeItem","processed","complete","errored","cancelled","loadStats","wasRunning","getItem","handlePreflight","handleCancel","handleConfirm","method","headers","body","JSON","stringify","Error","setItem","err","message","String","progressPercent","total","Math","round","showProgressBar","statsPercent","allOptimized","div","style","padding","borderBottom","display","alignItems","gap","flexWrap","button","onClick","disabled","backgroundColor","color","border","borderRadius","fontSize","fontWeight","cursor","span","label","input","type","checked","onChange","e","target","flex","minWidth","justifyContent","marginBottom","height","overflow","width","transition","marginLeft","flexDirection"],"mappings":"AAAA;;AAEA,OAAOA,SAASC,QAAQ,EAAEC,SAAS,EAAEC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AASvE,MAAMC,mBAAmB;AACzB,4EAA4E;AAC5E,yEAAyE;AACzE,MAAMC,kBAAkB;AACxB,MAAMC,cAAc;AAEpB,OAAO,MAAMC,qBAA+B;IAC1C,MAAM,CAACC,WAAWC,aAAa,GAAGT,SAAS;IAC3C,MAAM,CAACU,UAAUC,YAAY,GAAGX,SAAsC;IACtE,MAAM,CAACY,QAAQC,UAAU,GAAGb,SAAwB;IACpD,MAAM,CAACc,OAAOC,SAAS,GAAGf,SAAS;IACnC,MAAM,CAACgB,OAAOC,SAAS,GAAGjB,SAAwB;IAClD,MAAM,CAACkB,SAASC,WAAW,GAAGnB,SAAS;IACvC,MAAM,CAACoB,gBAAgBC,kBAAkB,GAAGrB,SAAwB;IACpE,MAAM,CAACsB,OAAOC,SAAS,GAAGvB,SAAsC;IAChE,MAAM,CAACwB,YAAYC,cAAc,GAAGzB,SAAS;IAC7C,MAAM0B,cAAcvB,OAA8C;IAClE,MAAMwB,WAAWxB,OAAO;QAAEyB,eAAe;QAAGC,YAAY;IAAE;IAC1D,MAAMC,mBAAmB3B,OAAO;IAEhC,2EAA2E;IAC3EF,UAAU;QACR,MAAM8B,OAAOC,OAAOC,QAAQ,CAACC,QAAQ,CAACC,KAAK,CAAC,gBAAgB,CAAC,EAAE,EAAEA,MAAM,IAAI,CAAC,EAAE,IAAI;QAClFd,kBAAkBU;IACpB,GAAG,EAAE;IAEL,yDAAyD;IACzD,MAAMK,aAAalC,YAAY;QAC7B,IAAI,CAACkB,gBAAgB;QACrB,IAAI;YACF,MAAMiB,MAAM,MAAMC,MAChB,CAAC,2CAA2C,EAAElB,gBAAgB;YAEhE,IAAIiB,IAAIE,EAAE,EAAE;gBACV,MAAMC,OAA6B,MAAMH,IAAII,IAAI;gBACjDlB,SAASiB;YACX;QACF,EAAE,OAAM;QACN,4BAA4B;QAC9B;IACF,GAAG;QAACpB;KAAe;IAEnB,MAAMsB,cAAcxC,YAAY;QAC9B,IAAIwB,YAAYiB,OAAO,EAAE;YACvBC,cAAclB,YAAYiB,OAAO;YACjCjB,YAAYiB,OAAO,GAAG;QACxB;IACF,GAAG,EAAE;IAEL,MAAME,eAAe3C,YACnB,CAAC4C;QACC,8BAA8B;QAC9BJ;QACAhB,YAAYiB,OAAO,GAAGI,YAAYD,QAAQ1C;IAC5C,GACA;QAACsC;KAAY;IAGf,MAAMM,eAAe9C,YAAY;QAC/B,IAAI,CAACkB,gBAAgB;QACrB,IAAI;YACF,MAAMiB,MAAM,MAAMC,MAChB,CAAC,2CAA2C,EAAElB,gBAAgB;YAEhE,IAAIiB,IAAIE,EAAE,EAAE;gBACV,MAAMC,OAA6B,MAAMH,IAAII,IAAI;gBACjD9B,YAAY6B;gBAEZ,oCAAoC;gBACpC,IAAIA,KAAKS,OAAO,IAAI,GAAG;oBACrBxC,aAAa;oBACbU,WAAW;oBACXuB;oBACAQ,eAAeC,UAAU,CAAC7C;oBAC1B;gBACF;gBAEA,wEAAwE;gBACxE,MAAM8C,YAAYZ,KAAKa,QAAQ,GAAGb,KAAKc,OAAO;gBAC9C,IAAIF,cAAczB,SAASgB,OAAO,CAACf,aAAa,EAAE;oBAChDD,SAASgB,OAAO,CAACd,UAAU,IAAI;gBACjC,OAAO;oBACLF,SAASgB,OAAO,CAACd,UAAU,GAAG;oBAC9BF,SAASgB,OAAO,CAACf,aAAa,GAAGwB;oBACjC,4CAA4C;oBAC5CjC,WAAW;gBACb;gBAEA,IAAIQ,SAASgB,OAAO,CAACd,UAAU,IAAIxB,iBAAiB;oBAClDc,WAAW;gBACX,uDAAuD;gBACzD;YACF;QACF,EAAE,OAAM;QACN,wBAAwB;QAC1B;IACF,GAAG;QAACC;QAAgBsB;KAAY;IAEhC,wEAAwE;IACxE,2EAA2E;IAC3E,qEAAqE;IACrEzC,UAAU;QACR,IAAI,CAACmB,gBAAgB;QACrB,IAAImC,YAAY;QAChB,MAAMC,YAAY;YAChB,IAAI;gBACF,MAAMnB,MAAM,MAAMC,MAChB,CAAC,2CAA2C,EAAElB,gBAAgB;gBAEhE,IAAI,CAACiB,IAAIE,EAAE,IAAIgB,WAAW;gBAC1B,MAAMf,OAA6B,MAAMH,IAAII,IAAI;gBACjDlB,SAASiB;gBAET,yEAAyE;gBACzE,MAAMiB,aAAaP,eAAeQ,OAAO,CAACpD,iBAAiBc;gBAC3D,IAAIqC,cAAcjB,KAAKS,OAAO,GAAG,GAAG;oBAClCtC,YAAY6B;oBACZ/B,aAAa;oBACbU,WAAW;oBACXQ,SAASgB,OAAO,GAAG;wBAAEf,eAAeY,KAAKa,QAAQ,GAAGb,KAAKc,OAAO;wBAAEzB,YAAY;oBAAE;oBAChFgB,aAAaG;gBACf,OAAO,IAAIS,cAAcjB,KAAKS,OAAO,IAAI,GAAG;oBAC1C,oDAAoD;oBACpDC,eAAeC,UAAU,CAAC7C;gBAC5B;YACF,EAAE,OAAM;YACN,SAAS;YACX;QACF;QACAkD;QACA,OAAO;YACLD,YAAY;YACZb;QACF;IACF,GAAG;QAACtB;QAAgB4B;QAAcH;QAAcH;KAAY;IAE5D,sFAAsF;IACtFzC,UAAU;QACR,IAAI6B,iBAAiBa,OAAO,IAAI,CAACnC,WAAW;YAC1C4B;QACF;QACAN,iBAAiBa,OAAO,GAAGnC;IAC7B,GAAG;QAACA;QAAW4B;KAAW;IAE1B,yCAAyC;IACzC,MAAMuB,kBAAkB;QACtB,IAAI,CAACvC,gBAAgB;QACrBH,SAAS;QACT,2DAA2D;QAC3D,MAAMmB;QACNX,cAAc;IAChB;IAEA,MAAMmC,eAAe;QACnBnC,cAAc;IAChB;IAEA,6DAA6D;IAC7D,MAAMoC,gBAAgB;QACpB,IAAI,CAACzC,gBAAgB;QACrBK,cAAc;QACdR,SAAS;QACTE,WAAW;QACXV,aAAa;QACbI,UAAU;QACVF,YAAY;QACZgB,SAASgB,OAAO,GAAG;YAAEf,eAAe;YAAGC,YAAY;QAAE;QAErD,IAAI;YACF,MAAMQ,MAAM,MAAMC,MAAM,mCAAmC;gBACzDwB,QAAQ;gBACRC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBAAE9C;oBAAgBN;gBAAM;YAC/C;YAEA,IAAI,CAACuB,IAAIE,EAAE,EAAE;gBACX,MAAMC,OAAO,MAAMH,IAAII,IAAI;gBAC3B,MAAM,IAAI0B,MAAM3B,KAAKxB,KAAK,IAAI;YAChC;YAEA,MAAMwB,OAAO,MAAMH,IAAII,IAAI;YAC3B5B,UAAU2B,KAAK5B,MAAM;YAErB,IAAI4B,KAAK5B,MAAM,KAAK,GAAG;gBACrBH,aAAa;gBACb;YACF;YAEA,+DAA+D;YAC/DyC,eAAekB,OAAO,CAAC9D,aAAac;YACpC,gBAAgB;YAChByB,aAAaG;QACf,EAAE,OAAOqB,KAAK;YACZpD,SAASoD,eAAeF,QAAQE,IAAIC,OAAO,GAAGC,OAAOF;YACrD5D,aAAa;QACf;IACF;IAEA,8BAA8B;IAC9BR,UAAU;QACR,OAAO,IAAMyC;IACf,GAAG;QAACA;KAAY;IAEhB,IAAI,CAACtB,gBAAgB,OAAO;IAE5B,MAAMoD,kBACJ9D,YAAYA,SAAS+D,KAAK,GAAG,IACzBC,KAAKC,KAAK,CAAC,AAAEjE,CAAAA,SAAS2C,QAAQ,GAAG3C,SAAS4C,OAAO,AAAD,IAAK5C,SAAS+D,KAAK,GAAI,OACvE;IAEN,MAAMG,kBAAkB,AAACpE,aAAaE,YAAcQ,WAAWR;IAE/D,qBAAqB;IACrB,MAAMmE,eACJvD,SAASA,MAAMmD,KAAK,GAAG,IACnBC,KAAKC,KAAK,CAAC,AAACrD,MAAM+B,QAAQ,GAAG/B,MAAMmD,KAAK,GAAI,OAC5C;IACN,MAAMK,eAAexD,SAASA,MAAMmD,KAAK,GAAG,KAAKnD,MAAM+B,QAAQ,KAAK/B,MAAMmD,KAAK;IAE/E,qBACE,MAACM;QACCC,OAAO;YACLC,SAAS;YACTC,cAAc;YACdC,SAAS;YACTC,YAAY;YACZC,KAAK;YACLC,UAAU;QACZ;;YAEC,CAAC9D,4BACA,KAAC+D;gBACCC,SAAS7B;gBACT8B,UAAUjF;gBACVwE,OAAO;oBACLU,iBAAiBlF,YAAY,YAAY;oBACzCmF,OAAO;oBACPC,QAAQ;oBACRC,cAAc;oBACdZ,SAAS;oBACTa,UAAU;oBACVC,YAAY;oBACZC,QAAQxF,YAAY,gBAAgB;gBACtC;0BAECA,YAAY,6BAA6B;;YAI7CgB,cAAcF,uBACb,MAACyD;gBAAIC,OAAO;oBAAEG,SAAS;oBAAQC,YAAY;oBAAUC,KAAK;gBAAO;;kCAC/D,KAACY;wBAAKjB,OAAO;4BAAEc,UAAU;4BAAQH,OAAO;wBAAU;kCAC/C7E,QACG,CAAC,eAAe,EAAEQ,MAAMmD,KAAK,CAAC,qCAAqC,CAAC,GACpE,CAAC,WAAW,EAAEnD,MAAM2B,OAAO,CAAC,kBAAkB,EAAE3B,MAAM2B,OAAO,KAAK,IAAI,MAAM,GAAG,8BAA8B,CAAC;;kCAEpH,KAACsC;wBACCC,SAAS3B;wBACTmB,OAAO;4BACLU,iBAAiB;4BACjBC,OAAO;4BACPC,QAAQ;4BACRC,cAAc;4BACdZ,SAAS;4BACTa,UAAU;4BACVC,YAAY;4BACZC,QAAQ;wBACV;kCACD;;kCAGD,KAACT;wBACCC,SAAS5B;wBACToB,OAAO;4BACLU,iBAAiB;4BACjBC,OAAO;4BACPC,QAAQ;4BACRC,cAAc;4BACdZ,SAAS;4BACTa,UAAU;4BACVC,YAAY;4BACZC,QAAQ;wBACV;kCACD;;;;YAMJ,CAACxE,4BACA,MAAC0E;gBACClB,OAAO;oBAAEG,SAAS;oBAAQC,YAAY;oBAAUC,KAAK;oBAAOS,UAAU;gBAAO;;kCAE7E,KAACK;wBACCC,MAAK;wBACLC,SAASvF;wBACTwF,UAAU,CAACC,IAAMxF,SAASwF,EAAEC,MAAM,CAACH,OAAO;wBAC1CZ,UAAUjF;;oBACV;;;YAKLQ,uBACC,KAACiF;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;0BAAI9E;;YAGvDJ,WAAW,QAAQA,SAAS,KAAKJ,aAAa,CAACgB,4BAC9C,MAACyE;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;;oBAAG;oBAC3ClF;oBAAO;oBAAOA,WAAW,IAAI,MAAM;oBAAG;;;YAIjDA,WAAW,KAAK,CAACJ,aAAa,CAACU,WAAW,CAACM,4BAC1C,KAACyE;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;0BAAG;;YAKtD5E,WAAWR,0BACV,MAACuF;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;;oBAAG;oBACxBpF,SAASuC,OAAO;oBAAC;oBAAOvC,SAASuC,OAAO,KAAK,IAAI,MAAM;oBAAG;;;YAKxF2B,iCACC,MAACG;gBAAIC,OAAO;oBAAEyB,MAAM;oBAAGC,UAAU;gBAAQ;;kCACvC,MAAC3B;wBACCC,OAAO;4BACLG,SAAS;4BACTwB,gBAAgB;4BAChBb,UAAU;4BACVc,cAAc;wBAChB;;0CAEA,MAACX;;oCACEvF,SAAS2C,QAAQ;oCAAC;oCAAI3C,SAAS+D,KAAK;oCAAC;;;4BAEvC/D,SAAS4C,OAAO,GAAG,mBAClB,MAAC2C;gCAAKjB,OAAO;oCAAEW,OAAO;gCAAU;;oCAAIjF,SAAS4C,OAAO;oCAAC;;;0CAEvD,MAAC2C;;oCAAMzB;oCAAgB;;;;;kCAEzB,MAACO;wBACCC,OAAO;4BACL6B,QAAQ;4BACRnB,iBAAiB;4BACjBG,cAAc;4BACdiB,UAAU;4BACV3B,SAAS;wBACX;;0CAEA,KAACJ;gCACCC,OAAO;oCACL6B,QAAQ;oCACRE,OAAO,GAAGrG,SAAS+D,KAAK,GAAG,IAAIC,KAAKC,KAAK,CAAC,AAACjE,SAAS2C,QAAQ,GAAG3C,SAAS+D,KAAK,GAAI,OAAO,EAAE,CAAC,CAAC;oCAC5FiB,iBAAiB;oCACjBsB,YAAY;gCACd;;4BAEDtG,SAAS4C,OAAO,GAAG,mBAClB,KAACyB;gCACCC,OAAO;oCACL6B,QAAQ;oCACRE,OAAO,GAAGrG,SAAS+D,KAAK,GAAG,IAAIC,KAAKC,KAAK,CAAC,AAACjE,SAAS4C,OAAO,GAAG5C,SAAS+D,KAAK,GAAI,OAAO,EAAE,CAAC,CAAC;oCAC3FiB,iBAAiB;oCACjBsB,YAAY;gCACd;;;;;;YAOT,CAACxG,aAAa,CAACU,WAAWR,YAAYA,SAAS2C,QAAQ,GAAG,KAAKzC,WAAW,KAAK,CAACY,4BAC/E,MAACyE;gBAAKjB,OAAO;oBAAEc,UAAU;gBAAO;;kCAC9B,MAACG;wBAAKjB,OAAO;4BAAEW,OAAOjF,SAAS4C,OAAO,GAAG,IAAI,YAAY;wBAAU;;4BAAG;4BAC7D5C,SAAS2C,QAAQ;4BAAC;4BAAE3C,SAAS+D,KAAK;4BAAC;;;oBAE3C/D,SAAS4C,OAAO,GAAG,mBAClB,MAAC2C;wBAAKjB,OAAO;4BAAEW,OAAO;wBAAU;;4BAC7B;4BAAKjF,SAAS4C,OAAO;4BAAC;;;;;YAO9B,CAAC9C,aAAa,CAACU,WAAWI,SAASA,MAAMmD,KAAK,GAAG,mBAChD,MAACM;gBACCC,OAAO;oBACLiC,YAAY;oBACZ9B,SAAS;oBACT+B,eAAe;oBACf9B,YAAY;oBACZC,KAAK;oBACLqB,UAAU;gBACZ;;kCAEA,KAAC3B;wBAAIC,OAAO;4BAAEG,SAAS;4BAAQC,YAAY;4BAAUC,KAAK;4BAAOS,UAAU;wBAAO;kCAC/EhB,6BACC,MAACmB;4BAAKjB,OAAO;gCAAEW,OAAO;4BAAU;;gCAAG;gCACnBrE,MAAMmD,KAAK;gCAAC;;2CAG5B;;8CACE,MAACwB;oCAAKjB,OAAO;wCAAEW,OAAO;oCAAU;;wCAC7BrE,MAAM+B,QAAQ;wCAAC;wCAAE/B,MAAMmD,KAAK;wCAAC;;;gCAE/BnD,MAAMgC,OAAO,GAAG,mBACf;;sDACE,KAAC2C;4CAAKjB,OAAO;gDAAEW,OAAO;4CAAU;sDAAG;;sDACnC,MAACM;4CAAKjB,OAAO;gDAAEW,OAAO;4CAAU;;gDAAIrE,MAAMgC,OAAO;gDAAC;;;;;;;;oBAM3D,CAACwB,8BACA,KAACC;wBACCC,OAAO;4BACL+B,OAAO;4BACPF,QAAQ;4BACRnB,iBAAiB;4BACjBG,cAAc;4BACdiB,UAAU;wBACZ;kCAEA,cAAA,KAAC/B;4BACCC,OAAO;gCACL6B,QAAQ;gCACRE,OAAO,GAAGlC,aAAa,CAAC,CAAC;gCACzBa,iBAAiBpE,MAAMgC,OAAO,GAAG,IAAI,YAAY;gCACjDuC,cAAc;gCACdmB,YAAY;4BACd;;;;;;;AAQhB,EAAC"}
1
+ {"version":3,"sources":["../../src/components/RegenerationButton.tsx"],"sourcesContent":["'use client'\n\nimport React, { useState, useEffect, useCallback, useRef } from 'react'\nimport { useSelection } from '@payloadcms/ui'\n\ntype RegenerationProgress = {\n total: number\n complete: number\n errored: number\n pending: number\n}\n\nconst POLL_INTERVAL_MS = 2000\n// With sequential processing each image takes ~4-5s, so no progress for 30s\n// (15 polls) strongly suggests a real stall rather than slow processing.\nconst STALL_THRESHOLD = 15\nconst SESSION_KEY = 'imageOptimizer_running'\n\nexport const RegenerationButton: React.FC = () => {\n const { count: selectionCount, getSelectedIds } = useSelection()\n const hasSelection = selectionCount > 0\n const [isRunning, setIsRunning] = useState(false)\n const [progress, setProgress] = useState<RegenerationProgress | null>(null)\n const [queued, setQueued] = useState<number | null>(null)\n const [force, setForce] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [stalled, setStalled] = useState(false)\n const [collectionSlug, setCollectionSlug] = useState<string | null>(null)\n const [stats, setStats] = useState<RegenerationProgress | null>(null)\n const [confirming, setConfirming] = useState(false)\n const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n const stallRef = useRef({ lastProcessed: 0, stallCount: 0 })\n const prevIsRunningRef = useRef(false)\n\n // Extract collection slug from URL after mount to avoid hydration mismatch\n useEffect(() => {\n const slug = window.location.pathname.split('/collections/')[1]?.split('/')[0] ?? null\n setCollectionSlug(slug)\n }, [])\n\n // Fetch optimization stats (independent of regeneration)\n const fetchStats = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setStats(data)\n }\n } catch {\n // ignore stats fetch errors\n }\n }, [collectionSlug])\n\n const stopPolling = useCallback(() => {\n if (intervalRef.current) {\n clearInterval(intervalRef.current)\n intervalRef.current = null\n }\n }, [])\n\n const startPolling = useCallback(\n (pollFn: () => void) => {\n // Prevent duplicate intervals\n stopPolling()\n intervalRef.current = setInterval(pollFn, POLL_INTERVAL_MS)\n },\n [stopPolling],\n )\n\n const pollProgress = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setProgress(data)\n\n // Stop polling when no more pending\n if (data.pending <= 0) {\n setIsRunning(false)\n setStalled(false)\n stopPolling()\n sessionStorage.removeItem(SESSION_KEY)\n return\n }\n\n // Stall detection — warn but keep polling so we detect when jobs resume\n const processed = data.complete + data.errored\n if (processed === stallRef.current.lastProcessed) {\n stallRef.current.stallCount += 1\n } else {\n stallRef.current.stallCount = 0\n stallRef.current.lastProcessed = processed\n // Clear stall warning when progress resumes\n setStalled(false)\n }\n\n if (stallRef.current.stallCount >= STALL_THRESHOLD) {\n setStalled(true)\n // Keep polling — jobs may still be running server-side\n }\n }\n } catch {\n // ignore polling errors\n }\n }, [collectionSlug, stopPolling])\n\n // On mount: fetch stats for the counter display. If the user previously\n // triggered regeneration (sessionStorage flag) and there are still pending\n // images, resume polling so the UI reconnects after page navigation.\n useEffect(() => {\n if (!collectionSlug) return\n let cancelled = false\n const loadStats = async () => {\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (!res.ok || cancelled) return\n const data: RegenerationProgress = await res.json()\n setStats(data)\n\n // Resume polling only if the user triggered regeneration in this session\n const wasRunning = sessionStorage.getItem(SESSION_KEY) === collectionSlug\n if (wasRunning && data.pending > 0) {\n setProgress(data)\n setIsRunning(true)\n setStalled(false)\n stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }\n startPolling(pollProgress)\n } else if (wasRunning && data.pending <= 0) {\n // Jobs finished while we were away — clear the flag\n sessionStorage.removeItem(SESSION_KEY)\n }\n } catch {\n // ignore\n }\n }\n loadStats()\n return () => {\n cancelled = true\n stopPolling()\n }\n }, [collectionSlug, pollProgress, startPolling, stopPolling])\n\n // Refresh stats when regeneration finishes (isRunning transitions from true to false)\n useEffect(() => {\n if (prevIsRunningRef.current && !isRunning) {\n fetchStats()\n }\n prevIsRunningRef.current = isRunning\n }, [isRunning, fetchStats])\n\n // Phase 1: Show confirmation with counts\n const handlePreflight = async () => {\n if (!collectionSlug) return\n setError(null)\n // Refresh stats to get the latest counts before confirming\n await fetchStats()\n setConfirming(true)\n }\n\n const handleCancel = () => {\n setConfirming(false)\n }\n\n // Phase 2: Actually start regeneration (after user confirms)\n const handleConfirm = async () => {\n if (!collectionSlug) return\n setConfirming(false)\n setError(null)\n setStalled(false)\n setIsRunning(true)\n setQueued(null)\n setProgress(null)\n stallRef.current = { lastProcessed: 0, stallCount: 0 }\n\n try {\n const requestBody: Record<string, unknown> = { collectionSlug, force }\n if (hasSelection) {\n requestBody.docIds = getSelectedIds().map(String)\n }\n\n const res = await fetch('/api/image-optimizer/regenerate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(requestBody),\n })\n\n if (!res.ok) {\n const data = await res.json()\n throw new Error(data.error || 'Failed to start regeneration')\n }\n\n const data = await res.json()\n setQueued(data.queued)\n\n if (data.queued === 0) {\n setIsRunning(false)\n return\n }\n\n // Persist running state so we can resume after page navigation\n sessionStorage.setItem(SESSION_KEY, collectionSlug)\n // Start polling\n startPolling(pollProgress)\n } catch (err) {\n setError(err instanceof Error ? err.message : String(err))\n setIsRunning(false)\n }\n }\n\n // Cleanup interval on unmount\n useEffect(() => {\n return () => stopPolling()\n }, [stopPolling])\n\n if (!collectionSlug) return null\n\n const progressPercent =\n progress && progress.total > 0\n ? Math.round(((progress.complete + progress.errored) / progress.total) * 100)\n : 0\n\n const showProgressBar = (isRunning && progress) || (stalled && progress)\n\n // Stats computations\n const statsPercent =\n stats && stats.total > 0\n ? Math.round((stats.complete / stats.total) * 100)\n : 0\n const allOptimized = stats && stats.total > 0 && stats.complete === stats.total\n\n return (\n <div\n style={{\n padding: '16px 24px',\n borderBottom: '1px solid #e5e7eb',\n display: 'flex',\n alignItems: 'center',\n gap: '16px',\n flexWrap: 'wrap',\n }}\n >\n {!confirming && (\n <button\n onClick={handlePreflight}\n disabled={isRunning}\n style={{\n backgroundColor: isRunning ? '#9ca3af' : '#4f46e5',\n color: '#fff',\n border: 'none',\n borderRadius: '6px',\n padding: '8px 16px',\n fontSize: '14px',\n fontWeight: 500,\n cursor: isRunning ? 'not-allowed' : 'pointer',\n }}\n >\n {isRunning\n ? 'Processing images...'\n : hasSelection\n ? `Regenerate ${selectionCount} Selected`\n : 'Regenerate All Images'}\n </button>\n )}\n\n {confirming && stats && (\n <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>\n <span style={{ fontSize: '13px', color: '#374151' }}>\n {hasSelection\n ? `Regenerate ${selectionCount} selected image${selectionCount !== 1 ? 's' : ''}?`\n : force\n ? `Re-process all ${stats.total} images across the entire collection?`\n : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`}\n </span>\n <button\n onClick={handleConfirm}\n style={{\n backgroundColor: '#4f46e5',\n color: '#fff',\n border: 'none',\n borderRadius: '6px',\n padding: '6px 14px',\n fontSize: '13px',\n fontWeight: 500,\n cursor: 'pointer',\n }}\n >\n Confirm\n </button>\n <button\n onClick={handleCancel}\n style={{\n backgroundColor: 'transparent',\n color: '#6b7280',\n border: '1px solid #d1d5db',\n borderRadius: '6px',\n padding: '6px 14px',\n fontSize: '13px',\n fontWeight: 500,\n cursor: 'pointer',\n }}\n >\n Cancel\n </button>\n </div>\n )}\n\n {!confirming && (\n <label\n style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px' }}\n >\n <input\n type=\"checkbox\"\n checked={force}\n onChange={(e) => setForce(e.target.checked)}\n disabled={isRunning}\n />\n Force re-process all\n </label>\n )}\n\n {error && (\n <span style={{ color: '#ef4444', fontSize: '13px' }}>{error}</span>\n )}\n\n {queued !== null && queued > 0 && isRunning && !confirming && (\n <span style={{ color: '#4f46e5', fontSize: '13px' }}>\n Queued {queued} image{queued !== 1 ? 's' : ''} for processing\n </span>\n )}\n\n {queued === 0 && !isRunning && !stalled && !confirming && (\n <span style={{ color: '#10b981', fontSize: '13px' }}>\n All images already optimized.\n </span>\n )}\n\n {stalled && progress && (\n <span style={{ color: '#f59e0b', fontSize: '13px' }}>\n Processing appears slow — {progress.pending} image{progress.pending !== 1 ? 's' : ''} still pending.\n Jobs may still be running server-side.\n </span>\n )}\n\n {showProgressBar && (\n <div style={{ flex: 1, minWidth: '200px' }}>\n <div\n style={{\n display: 'flex',\n justifyContent: 'space-between',\n fontSize: '12px',\n marginBottom: '4px',\n }}\n >\n <span>\n {progress.complete} / {progress.total} complete\n </span>\n {progress.errored > 0 && (\n <span style={{ color: '#ef4444' }}>{progress.errored} errors</span>\n )}\n <span>{progressPercent}%</span>\n </div>\n <div\n style={{\n height: '6px',\n backgroundColor: '#e5e7eb',\n borderRadius: '3px',\n overflow: 'hidden',\n display: 'flex',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.complete / progress.total) * 100) : 0}%`,\n backgroundColor: '#10b981',\n transition: 'width 0.3s ease',\n }}\n />\n {progress.errored > 0 && (\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.errored / progress.total) * 100) : 0}%`,\n backgroundColor: '#ef4444',\n transition: 'width 0.3s ease',\n }}\n />\n )}\n </div>\n </div>\n )}\n\n {!isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && !confirming && (\n <span style={{ fontSize: '13px' }}>\n <span style={{ color: progress.errored > 0 ? '#f59e0b' : '#10b981' }}>\n Done! {progress.complete}/{progress.total} optimized (across entire collection).\n </span>\n {progress.errored > 0 && (\n <span style={{ color: '#ef4444' }}>\n {' '}{progress.errored} failed.\n </span>\n )}\n </span>\n )}\n\n {/* Persistent optimization stats — always visible when not actively regenerating */}\n {!isRunning && !stalled && stats && stats.total > 0 && (\n <div\n style={{\n marginLeft: 'auto',\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-end',\n gap: '4px',\n minWidth: '180px',\n }}\n >\n <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>\n {allOptimized ? (\n <span style={{ color: '#10b981' }}>\n &#10003; All {stats.total} images optimized\n </span>\n ) : (\n <>\n <span style={{ color: '#6b7280' }}>\n {stats.complete}/{stats.total} optimized\n </span>\n {stats.errored > 0 && (\n <>\n <span style={{ color: '#d1d5db' }}>&middot;</span>\n <span style={{ color: '#ef4444' }}>{stats.errored} errors</span>\n </>\n )}\n </>\n )}\n </div>\n {!allOptimized && (\n <div\n style={{\n width: '100%',\n height: '3px',\n backgroundColor: '#e5e7eb',\n borderRadius: '2px',\n overflow: 'hidden',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${statsPercent}%`,\n backgroundColor: stats.errored > 0 ? '#f59e0b' : '#10b981',\n borderRadius: '2px',\n transition: 'width 0.3s ease',\n }}\n />\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":["React","useState","useEffect","useCallback","useRef","useSelection","POLL_INTERVAL_MS","STALL_THRESHOLD","SESSION_KEY","RegenerationButton","count","selectionCount","getSelectedIds","hasSelection","isRunning","setIsRunning","progress","setProgress","queued","setQueued","force","setForce","error","setError","stalled","setStalled","collectionSlug","setCollectionSlug","stats","setStats","confirming","setConfirming","intervalRef","stallRef","lastProcessed","stallCount","prevIsRunningRef","slug","window","location","pathname","split","fetchStats","res","fetch","ok","data","json","stopPolling","current","clearInterval","startPolling","pollFn","setInterval","pollProgress","pending","sessionStorage","removeItem","processed","complete","errored","cancelled","loadStats","wasRunning","getItem","handlePreflight","handleCancel","handleConfirm","requestBody","docIds","map","String","method","headers","body","JSON","stringify","Error","setItem","err","message","progressPercent","total","Math","round","showProgressBar","statsPercent","allOptimized","div","style","padding","borderBottom","display","alignItems","gap","flexWrap","button","onClick","disabled","backgroundColor","color","border","borderRadius","fontSize","fontWeight","cursor","span","label","input","type","checked","onChange","e","target","flex","minWidth","justifyContent","marginBottom","height","overflow","width","transition","marginLeft","flexDirection"],"mappings":"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"}
package/dist/defaults.js CHANGED
@@ -14,7 +14,8 @@ export const resolveConfig = (config)=>({
14
14
  height: 2560
15
15
  },
16
16
  replaceOriginal: config.replaceOriginal ?? true,
17
- stripMetadata: config.stripMetadata ?? true
17
+ stripMetadata: config.stripMetadata ?? true,
18
+ uniqueFileNames: config.uniqueFileNames ?? false
18
19
  });
19
20
  export const resolveCollectionConfig = (resolvedConfig, collectionSlug)=>{
20
21
  const collectionValue = resolvedConfig.collections[collectionSlug];
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nimport type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'\n\nexport const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({\n clientOptimization: config.clientOptimization ?? true,\n collections: config.collections,\n disabled: config.disabled ?? false,\n formats: config.formats ?? [\n { format: 'webp', quality: 80 },\n ],\n generateThumbHash: config.generateThumbHash ?? true,\n maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },\n replaceOriginal: config.replaceOriginal ?? true,\n stripMetadata: config.stripMetadata ?? true,\n})\n\nexport const resolveCollectionConfig = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): ResolvedCollectionOptimizerConfig => {\n const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]\n\n if (!collectionValue || collectionValue === true) {\n return {\n formats: resolvedConfig.formats,\n maxDimensions: resolvedConfig.maxDimensions,\n replaceOriginal: resolvedConfig.replaceOriginal,\n }\n }\n\n return {\n formats: collectionValue.formats ?? resolvedConfig.formats,\n maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,\n replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal,\n }\n}\n"],"names":["resolveConfig","config","clientOptimization","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,oBAAoBD,OAAOC,kBAAkB,IAAI;QACjDC,aAAaF,OAAOE,WAAW;QAC/BC,UAAUH,OAAOG,QAAQ,IAAI;QAC7BC,SAASJ,OAAOI,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBP,OAAOO,iBAAiB,IAAI;QAC/CC,eAAeR,OAAOQ,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBX,OAAOW,eAAe,IAAI;QAC3CC,eAAeZ,OAAOY,aAAa,IAAI;IACzC,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeZ,WAAW,CAACa,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLZ,SAASU,eAAeV,OAAO;YAC/BI,eAAeM,eAAeN,aAAa;YAC3CG,iBAAiBG,eAAeH,eAAe;QACjD;IACF;IAEA,OAAO;QACLP,SAASY,gBAAgBZ,OAAO,IAAIU,eAAeV,OAAO;QAC1DI,eAAeQ,gBAAgBR,aAAa,IAAIM,eAAeN,aAAa;QAC5EG,iBAAiBK,gBAAgBL,eAAe,IAAIG,eAAeH,eAAe;IACpF;AACF,EAAC"}
1
+ {"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nimport type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'\n\nexport const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({\n clientOptimization: config.clientOptimization ?? true,\n collections: config.collections,\n disabled: config.disabled ?? false,\n formats: config.formats ?? [\n { format: 'webp', quality: 80 },\n ],\n generateThumbHash: config.generateThumbHash ?? true,\n maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },\n replaceOriginal: config.replaceOriginal ?? true,\n stripMetadata: config.stripMetadata ?? true,\n uniqueFileNames: config.uniqueFileNames ?? false,\n})\n\nexport const resolveCollectionConfig = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): ResolvedCollectionOptimizerConfig => {\n const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]\n\n if (!collectionValue || collectionValue === true) {\n return {\n formats: resolvedConfig.formats,\n maxDimensions: resolvedConfig.maxDimensions,\n replaceOriginal: resolvedConfig.replaceOriginal,\n }\n }\n\n return {\n formats: collectionValue.formats ?? resolvedConfig.formats,\n maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,\n replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal,\n }\n}\n"],"names":["resolveConfig","config","clientOptimization","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","uniqueFileNames","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,oBAAoBD,OAAOC,kBAAkB,IAAI;QACjDC,aAAaF,OAAOE,WAAW;QAC/BC,UAAUH,OAAOG,QAAQ,IAAI;QAC7BC,SAASJ,OAAOI,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBP,OAAOO,iBAAiB,IAAI;QAC/CC,eAAeR,OAAOQ,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBX,OAAOW,eAAe,IAAI;QAC3CC,eAAeZ,OAAOY,aAAa,IAAI;QACvCC,iBAAiBb,OAAOa,eAAe,IAAI;IAC7C,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeb,WAAW,CAACc,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLb,SAASW,eAAeX,OAAO;YAC/BI,eAAeO,eAAeP,aAAa;YAC3CG,iBAAiBI,eAAeJ,eAAe;QACjD;IACF;IAEA,OAAO;QACLP,SAASa,gBAAgBb,OAAO,IAAIW,eAAeX,OAAO;QAC1DI,eAAeS,gBAAgBT,aAAa,IAAIO,eAAeP,aAAa;QAC5EG,iBAAiBM,gBAAgBN,eAAe,IAAII,eAAeJ,eAAe;IACpF;AACF,EAAC"}
@@ -22,59 +22,73 @@ export const createRegenerateHandler = (resolvedConfig)=>{
22
22
  status: 400
23
23
  });
24
24
  }
25
- // Find all image documents in the collection
26
- // Unless force=true, skip already-processed docs
27
- const where = body.force ? {
28
- mimeType: {
29
- contains: 'image/'
30
- }
31
- } : {
32
- and: [
33
- {
34
- mimeType: {
35
- contains: 'image/'
36
- }
37
- },
38
- {
39
- or: [
40
- {
41
- 'imageOptimizer.status': {
42
- not_equals: 'complete'
43
- }
44
- },
45
- {
46
- 'imageOptimizer.status': {
47
- exists: false
48
- }
49
- }
50
- ]
51
- }
52
- ]
53
- };
54
25
  let queued = 0;
55
- let page = 1;
56
- let hasMore = true;
57
- while(hasMore){
58
- const result = await req.payload.find({
59
- collection: collectionSlug,
60
- limit: 50,
61
- page,
62
- depth: 0,
63
- where,
64
- sort: 'createdAt'
65
- });
66
- for (const doc of result.docs){
26
+ if (body.docIds && body.docIds.length > 0) {
27
+ // Regenerate specific documents by ID
28
+ for (const docId of body.docIds){
67
29
  await req.payload.jobs.queue({
68
30
  task: 'imageOptimizer_regenerateDocument',
69
31
  input: {
70
32
  collectionSlug,
71
- docId: String(doc.id)
33
+ docId: String(docId)
72
34
  }
73
35
  });
74
36
  queued++;
75
37
  }
76
- hasMore = result.hasNextPage;
77
- page++;
38
+ } else {
39
+ // Find all image documents in the collection
40
+ // Unless force=true, skip already-processed docs
41
+ const where = body.force ? {
42
+ mimeType: {
43
+ contains: 'image/'
44
+ }
45
+ } : {
46
+ and: [
47
+ {
48
+ mimeType: {
49
+ contains: 'image/'
50
+ }
51
+ },
52
+ {
53
+ or: [
54
+ {
55
+ 'imageOptimizer.status': {
56
+ not_equals: 'complete'
57
+ }
58
+ },
59
+ {
60
+ 'imageOptimizer.status': {
61
+ exists: false
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ ]
67
+ };
68
+ let page = 1;
69
+ let hasMore = true;
70
+ while(hasMore){
71
+ const result = await req.payload.find({
72
+ collection: collectionSlug,
73
+ limit: 50,
74
+ page,
75
+ depth: 0,
76
+ where,
77
+ sort: 'createdAt'
78
+ });
79
+ for (const doc of result.docs){
80
+ await req.payload.jobs.queue({
81
+ task: 'imageOptimizer_regenerateDocument',
82
+ input: {
83
+ collectionSlug,
84
+ docId: String(doc.id)
85
+ }
86
+ });
87
+ queued++;
88
+ }
89
+ hasMore = result.hasNextPage;
90
+ page++;
91
+ }
78
92
  }
79
93
  req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`);
80
94
  // Fire the job runner — use waitUntil to keep the serverless function alive
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/regenerate.ts"],"sourcesContent":["import type { PayloadHandler } from 'payload'\nimport type { CollectionSlug, Where } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { waitUntil } from '../utilities/waitUntil.js'\n\nexport const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: { collectionSlug?: string; force?: boolean }\n try {\n body = await req.json!()\n } catch {\n body = {}\n }\n\n const collectionSlug = body.collectionSlug\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json(\n { error: 'Invalid or unconfigured collection slug' },\n { status: 400 },\n )\n }\n\n // Find all image documents in the collection\n // Unless force=true, skip already-processed docs\n const where: Where = body.force\n ? { mimeType: { contains: 'image/' } }\n : {\n and: [\n { mimeType: { contains: 'image/' } },\n {\n or: [\n { 'imageOptimizer.status': { not_equals: 'complete' } },\n { 'imageOptimizer.status': { exists: false } },\n ],\n },\n ],\n }\n\n let queued = 0\n let page = 1\n let hasMore = true\n\n while (hasMore) {\n const result = await req.payload.find({\n collection: collectionSlug as CollectionSlug,\n limit: 50,\n page,\n depth: 0,\n where,\n sort: 'createdAt',\n })\n\n for (const doc of result.docs) {\n await req.payload.jobs.queue({\n task: 'imageOptimizer_regenerateDocument',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n queued++\n }\n\n hasMore = result.hasNextPage\n page++\n }\n\n req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`)\n\n // Fire the job runner — use waitUntil to keep the serverless function alive\n // after the response is sent, so jobs actually complete on Vercel/serverless.\n if (queued > 0) {\n const runPromise = req.payload.jobs.run({ limit: queued, sequential: true }).catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Regeneration job runner failed')\n })\n waitUntil(runPromise, req)\n }\n\n return Response.json({ queued, collectionSlug })\n }\n\n return handler\n}\n\nexport const createRegenerateStatusHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url!)\n const collectionSlug = url.searchParams.get('collection')\n\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json({ error: 'Invalid collection slug' }, { status: 400 })\n }\n\n const total = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: { mimeType: { contains: 'image/' } },\n })\n\n const complete = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'complete' },\n },\n })\n\n const errored = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'error' },\n },\n })\n\n return Response.json({\n collectionSlug,\n total: total.totalDocs,\n complete: complete.totalDocs,\n errored: errored.totalDocs,\n pending: total.totalDocs - complete.totalDocs - errored.totalDocs,\n })\n }\n\n return handler\n}\n"],"names":["waitUntil","createRegenerateHandler","resolvedConfig","handler","req","user","Response","json","error","status","body","collectionSlug","collections","where","force","mimeType","contains","and","or","not_equals","exists","queued","page","hasMore","result","payload","find","collection","limit","depth","sort","doc","docs","jobs","queue","task","input","docId","String","id","hasNextPage","logger","info","runPromise","run","sequential","catch","err","createRegenerateStatusHandler","url","URL","searchParams","get","total","count","complete","equals","errored","totalDocs","pending"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/regenerate.ts"],"sourcesContent":["import type { PayloadHandler } from 'payload'\nimport type { CollectionSlug, Where } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { waitUntil } from '../utilities/waitUntil.js'\n\nexport const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: { collectionSlug?: string; force?: boolean; docIds?: string[] }\n try {\n body = await req.json!()\n } catch {\n body = {}\n }\n\n const collectionSlug = body.collectionSlug\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json(\n { error: 'Invalid or unconfigured collection slug' },\n { status: 400 },\n )\n }\n\n let queued = 0\n\n if (body.docIds && body.docIds.length > 0) {\n // Regenerate specific documents by ID\n for (const docId of body.docIds) {\n await req.payload.jobs.queue({\n task: 'imageOptimizer_regenerateDocument',\n input: {\n collectionSlug,\n docId: String(docId),\n },\n })\n queued++\n }\n } else {\n // Find all image documents in the collection\n // Unless force=true, skip already-processed docs\n const where: Where = body.force\n ? { mimeType: { contains: 'image/' } }\n : {\n and: [\n { mimeType: { contains: 'image/' } },\n {\n or: [\n { 'imageOptimizer.status': { not_equals: 'complete' } },\n { 'imageOptimizer.status': { exists: false } },\n ],\n },\n ],\n }\n\n let page = 1\n let hasMore = true\n\n while (hasMore) {\n const result = await req.payload.find({\n collection: collectionSlug as CollectionSlug,\n limit: 50,\n page,\n depth: 0,\n where,\n sort: 'createdAt',\n })\n\n for (const doc of result.docs) {\n await req.payload.jobs.queue({\n task: 'imageOptimizer_regenerateDocument',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n queued++\n }\n\n hasMore = result.hasNextPage\n page++\n }\n }\n\n req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`)\n\n // Fire the job runner — use waitUntil to keep the serverless function alive\n // after the response is sent, so jobs actually complete on Vercel/serverless.\n if (queued > 0) {\n const runPromise = req.payload.jobs.run({ limit: queued, sequential: true }).catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Regeneration job runner failed')\n })\n waitUntil(runPromise, req)\n }\n\n return Response.json({ queued, collectionSlug })\n }\n\n return handler\n}\n\nexport const createRegenerateStatusHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url!)\n const collectionSlug = url.searchParams.get('collection')\n\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json({ error: 'Invalid collection slug' }, { status: 400 })\n }\n\n const total = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: { mimeType: { contains: 'image/' } },\n })\n\n const complete = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'complete' },\n },\n })\n\n const errored = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'error' },\n },\n })\n\n return Response.json({\n collectionSlug,\n total: total.totalDocs,\n complete: complete.totalDocs,\n errored: errored.totalDocs,\n pending: total.totalDocs - complete.totalDocs - errored.totalDocs,\n })\n }\n\n return handler\n}\n"],"names":["waitUntil","createRegenerateHandler","resolvedConfig","handler","req","user","Response","json","error","status","body","collectionSlug","collections","queued","docIds","length","docId","payload","jobs","queue","task","input","String","where","force","mimeType","contains","and","or","not_equals","exists","page","hasMore","result","find","collection","limit","depth","sort","doc","docs","id","hasNextPage","logger","info","runPromise","run","sequential","catch","err","createRegenerateStatusHandler","url","URL","searchParams","get","total","count","complete","equals","errored","totalDocs","pending"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import crypto from 'crypto';
1
2
  import path from 'path';
2
3
  import { resolveCollectionConfig } from '../defaults.js';
3
4
  import { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js';
@@ -6,6 +7,15 @@ export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
6
7
  return async ({ context, data, req })=>{
7
8
  if (context?.imageOptimizer_skip) return data;
8
9
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data;
10
+ // Rename file to UUID before any processing, so the storage adapter
11
+ // never sees the original filename. Prevents Vercel Blob "already exists"
12
+ // errors and avoids leaking original filenames to storage.
13
+ if (resolvedConfig.uniqueFileNames) {
14
+ const ext = path.extname(req.file.name);
15
+ const uuid = crypto.randomUUID();
16
+ req.file.name = `${uuid}${ext}`;
17
+ data.filename = req.file.name;
18
+ }
9
19
  const originalSize = req.file.data.length;
10
20
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
11
21
  // Process in memory: strip EXIF, resize, generate blur
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\nimport { isCloudStorage } from '../utilities/storage.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n // Determine if async work (variant generation job) is needed after create.\n // If not, set status to 'complete' now so afterChange doesn't need a separate\n // update() call — which fails with 404 on MongoDB due to transaction isolation\n // when cloud storage adapters are involved.\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n const needsAsyncJob = !cloudStorage && perCollectionConfig.formats.length > 0 && !(perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1)\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: needsAsyncJob ? 'pending' : 'complete',\n variants: needsAsyncJob ? undefined : [],\n error: null,\n }\n\n if (!needsAsyncJob) {\n context.imageOptimizer_statusResolved = true\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Write processed buffer back to req.file so cloud storage adapters\n // (which read req.file in their afterChange hook) upload the optimized version.\n // Payload's own uploadFiles step does NOT re-read req.file.data for its local\n // disk write, so we also store the buffer in context for our afterChange hook\n // to overwrite the local file when local storage is enabled.\n req.file.data = finalBuffer\n req.file.size = finalSize\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n req.file.name = data.filename\n req.file.mimetype = data.mimeType\n }\n context.imageOptimizer_processedBuffer = finalBuffer\n context.imageOptimizer_hasUpload = true\n\n return data\n }\n}\n"],"names":["path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","isCloudStorage","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","filename","name","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","collectionConfig","payload","collections","config","cloudStorage","needsAsyncJob","imageOptimizer","optimizedSize","status","variants","undefined","error","imageOptimizer_statusResolved","thumbHash","imageOptimizer_processedBuffer","imageOptimizer_hasUpload"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import crypto from 'crypto'\nimport path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\nimport { isCloudStorage } from '../utilities/storage.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n // Rename file to UUID before any processing, so the storage adapter\n // never sees the original filename. Prevents Vercel Blob \"already exists\"\n // errors and avoids leaking original filenames to storage.\n if (resolvedConfig.uniqueFileNames) {\n const ext = path.extname(req.file.name)\n const uuid = crypto.randomUUID()\n req.file.name = `${uuid}${ext}`\n data.filename = req.file.name\n }\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n // Determine if async work (variant generation job) is needed after create.\n // If not, set status to 'complete' now so afterChange doesn't need a separate\n // update() call — which fails with 404 on MongoDB due to transaction isolation\n // when cloud storage adapters are involved.\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n const needsAsyncJob = !cloudStorage && perCollectionConfig.formats.length > 0 && !(perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1)\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: needsAsyncJob ? 'pending' : 'complete',\n variants: needsAsyncJob ? undefined : [],\n error: null,\n }\n\n if (!needsAsyncJob) {\n context.imageOptimizer_statusResolved = true\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Write processed buffer back to req.file so cloud storage adapters\n // (which read req.file in their afterChange hook) upload the optimized version.\n // Payload's own uploadFiles step does NOT re-read req.file.data for its local\n // disk write, so we also store the buffer in context for our afterChange hook\n // to overwrite the local file when local storage is enabled.\n req.file.data = finalBuffer\n req.file.size = finalSize\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n req.file.name = data.filename\n req.file.mimetype = data.mimeType\n }\n context.imageOptimizer_processedBuffer = finalBuffer\n context.imageOptimizer_hasUpload = true\n\n return data\n }\n}\n"],"names":["crypto","path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","isCloudStorage","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","uniqueFileNames","ext","extname","name","uuid","randomUUID","filename","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","collectionConfig","payload","collections","config","cloudStorage","needsAsyncJob","imageOptimizer","optimizedSize","status","variants","undefined","error","imageOptimizer_statusResolved","thumbHash","imageOptimizer_processedBuffer","imageOptimizer_hasUpload"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import crypto from 'crypto';
1
2
  import fs from 'fs/promises';
2
3
  import path from 'path';
3
4
  import { resolveCollectionConfig } from '../defaults.js';
@@ -52,6 +53,12 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
52
53
  if (cloudStorage) {
53
54
  // Cloud storage: re-upload the optimized file via Payload's update API.
54
55
  // This triggers the cloud adapter's afterChange hook which uploads to cloud.
56
+ // When uniqueFileNames is enabled, generate a new UUID filename to avoid
57
+ // Vercel Blob "already exists" errors (the adapter doesn't support allowOverwrite).
58
+ if (resolvedConfig.uniqueFileNames) {
59
+ const ext = path.extname(newFilename);
60
+ newFilename = `${crypto.randomUUID()}${ext}`;
61
+ }
55
62
  const updateData = {
56
63
  imageOptimizer: {
57
64
  originalSize,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Store the optimized file\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n if (cloudStorage) {\n // Cloud storage: re-upload the optimized file via Payload's update API.\n // This triggers the cloud adapter's afterChange hook which uploads to cloud.\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants: [],\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n file: {\n data: mainBuffer,\n mimetype: newMimeType || doc.mimeType,\n name: newFilename,\n size: mainSize,\n },\n context: { imageOptimizer_skip: true },\n })\n } else {\n // Local storage: write files to disk\n const staticDir = resolveStaticDir(collectionConfig)\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, safeFilename)\n await fs.unlink(oldFilePath).catch(() => {})\n }\n\n // Generate variant files (local storage only)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Update the document with optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n }\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr, docId: input.docId, collectionSlug: input.collectionSlug },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","cloudStorage","fileBuffer","originalSize","length","perCollectionConfig","safeFilename","basename","filename","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","thumbHash","variants","updateData","imageOptimizer","optimizedSize","error","filesize","update","data","file","mimetype","context","imageOptimizer_skip","staticDir","newFilePath","join","writeFile","oldFilePath","unlink","catch","formatsToGenerate","slice","result","variantFilename","push","width","height","url","err","errorMessage","Error","message","String","updateErr","logger"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import crypto from 'crypto'\nimport fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Store the optimized file\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n if (cloudStorage) {\n // Cloud storage: re-upload the optimized file via Payload's update API.\n // This triggers the cloud adapter's afterChange hook which uploads to cloud.\n // When uniqueFileNames is enabled, generate a new UUID filename to avoid\n // Vercel Blob \"already exists\" errors (the adapter doesn't support allowOverwrite).\n if (resolvedConfig.uniqueFileNames) {\n const ext = path.extname(newFilename)\n newFilename = `${crypto.randomUUID()}${ext}`\n }\n\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants: [],\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n file: {\n data: mainBuffer,\n mimetype: newMimeType || doc.mimeType,\n name: newFilename,\n size: mainSize,\n },\n context: { imageOptimizer_skip: true },\n })\n } else {\n // Local storage: write files to disk\n const staticDir = resolveStaticDir(collectionConfig)\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, safeFilename)\n await fs.unlink(oldFilePath).catch(() => {})\n }\n\n // Generate variant files (local storage only)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Update the document with optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n }\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr, docId: input.docId, collectionSlug: input.collectionSlug },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["crypto","fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","cloudStorage","fileBuffer","originalSize","length","perCollectionConfig","safeFilename","basename","filename","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","thumbHash","variants","uniqueFileNames","ext","extname","randomUUID","updateData","imageOptimizer","optimizedSize","error","filesize","update","data","file","mimetype","context","imageOptimizer_skip","staticDir","newFilePath","join","writeFile","oldFilePath","unlink","catch","formatsToGenerate","slice","result","variantFilename","push","width","height","url","err","errorMessage","Error","message","String","updateErr","logger"],"mappings":"AAAA,OAAOA,YAAY,SAAQ;AAC3B,OAAOC,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAKvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,cAAc,EAAEC,iBAAiB,EAAEC,aAAa,QAAQ,yBAAwB;AACzF,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,eAAe,EAAEC,cAAc,QAAQ,0BAAyB;AAEzE,OAAO,MAAMC,kCAAkC,CAACC;IAC9C,OAAO,OAAO,EAAEC,KAAK,EAAEC,GAAG,EAAkE;QAC1F,IAAI;YACF,MAAMC,MAAM,MAAMD,IAAIE,OAAO,CAACC,QAAQ,CAAC;gBACrCC,YAAYL,MAAMM,cAAc;gBAChCC,IAAIP,MAAMQ,KAAK;YACjB;YAEA,2BAA2B;YAC3B,IAAI,CAACN,IAAIO,QAAQ,IAAI,CAACP,IAAIO,QAAQ,CAACC,UAAU,CAAC,WAAW;gBACvD,OAAO;oBAAEC,QAAQ;wBAAEC,QAAQ;wBAAWC,QAAQ;oBAAY;gBAAE;YAC9D;YAEA,MAAMC,mBAAmBb,IAAIE,OAAO,CAACY,WAAW,CAACf,MAAMM,cAAc,CAAyC,CAACU,MAAM;YACrH,MAAMC,eAAepB,eAAeiB;YAEpC,MAAMI,aAAa,MAAMtB,gBAAgBM,KAAKY;YAC9C,MAAMK,eAAeD,WAAWE,MAAM;YACtC,MAAMC,sBAAsB9B,wBAAwBQ,gBAAgBC,MAAMM,cAAc;YAExF,8CAA8C;YAC9C,MAAMgB,eAAehC,KAAKiC,QAAQ,CAACrB,IAAIsB,QAAQ;YAE/C,kCAAkC;YAClC,MAAMC,YAAY,MAAMjC,eACtB0B,YACAG,oBAAoBK,aAAa,EACjC3B,eAAe4B,aAAa;YAG9B,IAAIC,aAAaH,UAAUI,MAAM;YACjC,IAAIC,WAAWL,UAAUM,IAAI;YAC7B,IAAIC,cAAcV;YAClB,IAAIW;YAEJ,mEAAmE;YACnE,IAAIZ,oBAAoBa,eAAe,IAAIb,oBAAoBc,OAAO,CAACf,MAAM,GAAG,GAAG;gBACjF,MAAMgB,gBAAgBf,oBAAoBc,OAAO,CAAC,EAAE;gBACpD,MAAME,YAAY,MAAM3C,cAAc+B,UAAUI,MAAM,EAAEO,cAAcE,MAAM,EAAEF,cAAcG,OAAO;gBACnGX,aAAaS,UAAUR,MAAM;gBAC7BC,WAAWO,UAAUN,IAAI;gBACzBC,cAAc,GAAG1C,KAAKkD,KAAK,CAAClB,cAAcmB,IAAI,CAAC,CAAC,EAAEL,cAAcE,MAAM,EAAE;gBACxEL,cAAcI,UAAU5B,QAAQ;YAClC;YAEA,6BAA6B;YAC7B,IAAIiC;YACJ,IAAI3C,eAAeN,iBAAiB,EAAE;gBACpCiD,YAAY,MAAMjD,kBAAkBmC;YACtC;YAEA,mCAAmC;YACnC,MAAMe,WAQD,EAAE;YAEP,IAAI1B,cAAc;gBAChB,wEAAwE;gBACxE,6EAA6E;gBAC7E,yEAAyE;gBACzE,oFAAoF;gBACpF,IAAIlB,eAAe6C,eAAe,EAAE;oBAClC,MAAMC,MAAMvD,KAAKwD,OAAO,CAACd;oBACzBA,cAAc,GAAG5C,OAAO2D,UAAU,KAAKF,KAAK;gBAC9C;gBAEA,MAAMG,aAAkC;oBACtCC,gBAAgB;wBACd9B;wBACA+B,eAAepB;wBACflB,QAAQ;wBACR8B;wBACAC,UAAU,EAAE;wBACZQ,OAAO;oBACT;gBACF;gBAEA,IAAInB,gBAAgBV,cAAc;oBAChC0B,WAAWxB,QAAQ,GAAGQ;oBACtBgB,WAAWI,QAAQ,GAAGtB;oBACtBkB,WAAWvC,QAAQ,GAAGwB;gBACxB;gBAEA,MAAMhC,IAAIE,OAAO,CAACkD,MAAM,CAAC;oBACvBhD,YAAYL,MAAMM,cAAc;oBAChCC,IAAIP,MAAMQ,KAAK;oBACf8C,MAAMN;oBACNO,MAAM;wBACJD,MAAM1B;wBACN4B,UAAUvB,eAAe/B,IAAIO,QAAQ;wBACrCgC,MAAMT;wBACND,MAAMD;oBACR;oBACA2B,SAAS;wBAAEC,qBAAqB;oBAAK;gBACvC;YACF,OAAO;gBACL,qCAAqC;gBACrC,MAAMC,YAAYhE,iBAAiBmB;gBACnC,MAAM8C,cAActE,KAAKuE,IAAI,CAACF,WAAW3B;gBACzC,MAAM3C,GAAGyE,SAAS,CAACF,aAAahC;gBAEhC,wCAAwC;gBACxC,IAAII,gBAAgBV,cAAc;oBAChC,MAAMyC,cAAczE,KAAKuE,IAAI,CAACF,WAAWrC;oBACzC,MAAMjC,GAAG2E,MAAM,CAACD,aAAaE,KAAK,CAAC,KAAO;gBAC5C;gBAEA,8CAA8C;gBAC9C,MAAMC,oBAAoB7C,oBAAoBa,eAAe,IAAIb,oBAAoBc,OAAO,CAACf,MAAM,GAAG,IAClGC,oBAAoBc,OAAO,CAACgC,KAAK,CAAC,KAClC9C,oBAAoBc,OAAO;gBAE/B,KAAK,MAAMG,UAAU4B,kBAAmB;oBACtC,MAAME,SAAS,MAAM1E,cAAckC,YAAYU,OAAOA,MAAM,EAAEA,OAAOC,OAAO;oBAC5E,MAAM8B,kBAAkB,GAAG/E,KAAKkD,KAAK,CAACR,aAAaS,IAAI,CAAC,WAAW,EAAEH,OAAOA,MAAM,EAAE;oBACpF,MAAMjD,GAAGyE,SAAS,CAACxE,KAAKuE,IAAI,CAACF,WAAWU,kBAAkBD,OAAOvC,MAAM;oBAEvEc,SAAS2B,IAAI,CAAC;wBACZhC,QAAQA,OAAOA,MAAM;wBACrBd,UAAU6C;wBACVjB,UAAUgB,OAAOrC,IAAI;wBACrBwC,OAAOH,OAAOG,KAAK;wBACnBC,QAAQJ,OAAOI,MAAM;wBACrB/D,UAAU2D,OAAO3D,QAAQ;wBACzBgE,KAAK,CAAC,KAAK,EAAEzE,MAAMM,cAAc,CAAC,MAAM,EAAE+D,iBAAiB;oBAC7D;gBACF;gBAEA,6CAA6C;gBAC7C,MAAMrB,aAAkC;oBACtCC,gBAAgB;wBACd9B;wBACA+B,eAAepB;wBACflB,QAAQ;wBACR8B;wBACAC;wBACAQ,OAAO;oBACT;gBACF;gBAEA,IAAInB,gBAAgBV,cAAc;oBAChC0B,WAAWxB,QAAQ,GAAGQ;oBACtBgB,WAAWI,QAAQ,GAAGtB;oBACtBkB,WAAWvC,QAAQ,GAAGwB;gBACxB;gBAEA,MAAMhC,IAAIE,OAAO,CAACkD,MAAM,CAAC;oBACvBhD,YAAYL,MAAMM,cAAc;oBAChCC,IAAIP,MAAMQ,KAAK;oBACf8C,MAAMN;oBACNS,SAAS;wBAAEC,qBAAqB;oBAAK;gBACvC;YACF;YAEA,OAAO;gBAAE/C,QAAQ;oBAAEC,QAAQ;gBAAW;YAAE;QAC1C,EAAE,OAAO8D,KAAK;YACZ,MAAMC,eAAeD,eAAeE,QAAQF,IAAIG,OAAO,GAAGC,OAAOJ;YAEjE,IAAI;gBACF,MAAMzE,IAAIE,OAAO,CAACkD,MAAM,CAAC;oBACvBhD,YAAYL,MAAMM,cAAc;oBAChCC,IAAIP,MAAMQ,KAAK;oBACf8C,MAAM;wBACJL,gBAAgB;4BACdrC,QAAQ;4BACRuC,OAAOwB;wBACT;oBACF;oBACAlB,SAAS;wBAAEC,qBAAqB;oBAAK;gBACvC;YACF,EAAE,OAAOqB,WAAW;gBAClB9E,IAAIE,OAAO,CAAC6E,MAAM,CAAC7B,KAAK,CACtB;oBAAEuB,KAAKK;oBAAWvE,OAAOR,MAAMQ,KAAK;oBAAEF,gBAAgBN,MAAMM,cAAc;gBAAC,GAC3E;YAEJ;YAEA,MAAMoE;QACR;IACF;AACF,EAAC"}
package/dist/types.d.ts CHANGED
@@ -28,6 +28,10 @@ export type ImageOptimizerConfig = {
28
28
  };
29
29
  replaceOriginal?: boolean;
30
30
  stripMetadata?: boolean;
31
+ /** Replace original filenames with UUIDs (e.g., `photo.jpg` → `a1b2c3d4.webp`).
32
+ * Prevents Vercel Blob "already exists" errors and avoids leaking original filenames.
33
+ * Defaults to `false`. */
34
+ uniqueFileNames?: boolean;
31
35
  };
32
36
  export type ResolvedCollectionOptimizerConfig = {
33
37
  formats: FormatQuality[];
@@ -42,6 +46,7 @@ export type ResolvedImageOptimizerConfig = Required<Pick<ImageOptimizerConfig, '
42
46
  collections: ImageOptimizerConfig['collections'];
43
47
  disabled: boolean;
44
48
  replaceOriginal: boolean;
49
+ uniqueFileNames: boolean;
45
50
  };
46
51
  export type ImageOptimizerData = {
47
52
  thumbHash?: string | null;
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug, Field } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n}\n\nexport type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\nexport type ImageOptimizerConfig = {\n clientOptimization?: boolean\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n fieldsOverride?: FieldsOverride\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n stripMetadata?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n replaceOriginal: boolean\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n clientOptimization: boolean\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n replaceOriginal: boolean\n}\n\nexport type ImageOptimizerData = {\n thumbHash?: string | null\n}\n\nexport type MediaSizeVariant = {\n url?: string | null\n width?: number | null\n height?: number | null\n mimeType?: string | null\n filesize?: number | null\n filename?: string | null\n}\n\nexport type MediaResource = {\n url?: string | null\n alt?: string | null\n width?: number | null\n height?: number | null\n filename?: string | null\n focalX?: number | null\n focalY?: number | null\n imageOptimizer?: ImageOptimizerData | null\n updatedAt?: string\n sizes?: Record<string, MediaSizeVariant | undefined>\n}\n"],"names":[],"mappings":"AAyDA,WAWC"}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug, Field } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n}\n\nexport type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\nexport type ImageOptimizerConfig = {\n clientOptimization?: boolean\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n fieldsOverride?: FieldsOverride\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n stripMetadata?: boolean\n /** Replace original filenames with UUIDs (e.g., `photo.jpg` → `a1b2c3d4.webp`).\n * Prevents Vercel Blob \"already exists\" errors and avoids leaking original filenames.\n * Defaults to `false`. */\n uniqueFileNames?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n replaceOriginal: boolean\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n clientOptimization: boolean\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n replaceOriginal: boolean\n uniqueFileNames: boolean\n}\n\nexport type ImageOptimizerData = {\n thumbHash?: string | null\n}\n\nexport type MediaSizeVariant = {\n url?: string | null\n width?: number | null\n height?: number | null\n mimeType?: string | null\n filesize?: number | null\n filename?: string | null\n}\n\nexport type MediaResource = {\n url?: string | null\n alt?: string | null\n width?: number | null\n height?: number | null\n filename?: string | null\n focalX?: number | null\n focalY?: number | null\n imageOptimizer?: ImageOptimizerData | null\n updatedAt?: string\n sizes?: Record<string, MediaSizeVariant | undefined>\n}\n"],"names":[],"mappings":"AA8DA,WAWC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import React, { useState, useEffect, useCallback, useRef } from 'react'
4
+ import { useSelection } from '@payloadcms/ui'
4
5
 
5
6
  type RegenerationProgress = {
6
7
  total: number
@@ -16,6 +17,8 @@ const STALL_THRESHOLD = 15
16
17
  const SESSION_KEY = 'imageOptimizer_running'
17
18
 
18
19
  export const RegenerationButton: React.FC = () => {
20
+ const { count: selectionCount, getSelectedIds } = useSelection()
21
+ const hasSelection = selectionCount > 0
19
22
  const [isRunning, setIsRunning] = useState(false)
20
23
  const [progress, setProgress] = useState<RegenerationProgress | null>(null)
21
24
  const [queued, setQueued] = useState<number | null>(null)
@@ -178,10 +181,15 @@ export const RegenerationButton: React.FC = () => {
178
181
  stallRef.current = { lastProcessed: 0, stallCount: 0 }
179
182
 
180
183
  try {
184
+ const requestBody: Record<string, unknown> = { collectionSlug, force }
185
+ if (hasSelection) {
186
+ requestBody.docIds = getSelectedIds().map(String)
187
+ }
188
+
181
189
  const res = await fetch('/api/image-optimizer/regenerate', {
182
190
  method: 'POST',
183
191
  headers: { 'Content-Type': 'application/json' },
184
- body: JSON.stringify({ collectionSlug, force }),
192
+ body: JSON.stringify(requestBody),
185
193
  })
186
194
 
187
195
  if (!res.ok) {
@@ -254,16 +262,22 @@ export const RegenerationButton: React.FC = () => {
254
262
  cursor: isRunning ? 'not-allowed' : 'pointer',
255
263
  }}
256
264
  >
257
- {isRunning ? 'Processing all images...' : 'Regenerate All Images'}
265
+ {isRunning
266
+ ? 'Processing images...'
267
+ : hasSelection
268
+ ? `Regenerate ${selectionCount} Selected`
269
+ : 'Regenerate All Images'}
258
270
  </button>
259
271
  )}
260
272
 
261
273
  {confirming && stats && (
262
274
  <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
263
275
  <span style={{ fontSize: '13px', color: '#374151' }}>
264
- {force
265
- ? `Re-process all ${stats.total} images across the entire collection?`
266
- : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`}
276
+ {hasSelection
277
+ ? `Regenerate ${selectionCount} selected image${selectionCount !== 1 ? 's' : ''}?`
278
+ : force
279
+ ? `Re-process all ${stats.total} images across the entire collection?`
280
+ : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`}
267
281
  </span>
268
282
  <button
269
283
  onClick={handleConfirm}
@@ -318,7 +332,7 @@ export const RegenerationButton: React.FC = () => {
318
332
 
319
333
  {queued !== null && queued > 0 && isRunning && !confirming && (
320
334
  <span style={{ color: '#4f46e5', fontSize: '13px' }}>
321
- Queued {queued} image{queued !== 1 ? 's' : ''} for processing across the entire collection
335
+ Queued {queued} image{queued !== 1 ? 's' : ''} for processing
322
336
  </span>
323
337
  )}
324
338
 
package/src/defaults.ts CHANGED
@@ -13,6 +13,7 @@ export const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimi
13
13
  maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },
14
14
  replaceOriginal: config.replaceOriginal ?? true,
15
15
  stripMetadata: config.stripMetadata ?? true,
16
+ uniqueFileNames: config.uniqueFileNames ?? false,
16
17
  })
17
18
 
18
19
  export const resolveCollectionConfig = (
@@ -10,7 +10,7 @@ export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerCo
10
10
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
11
11
  }
12
12
 
13
- let body: { collectionSlug?: string; force?: boolean }
13
+ let body: { collectionSlug?: string; force?: boolean; docIds?: string[] }
14
14
  try {
15
15
  body = await req.json!()
16
16
  } catch {
@@ -25,49 +25,64 @@ export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerCo
25
25
  )
26
26
  }
27
27
 
28
- // Find all image documents in the collection
29
- // Unless force=true, skip already-processed docs
30
- const where: Where = body.force
31
- ? { mimeType: { contains: 'image/' } }
32
- : {
33
- and: [
34
- { mimeType: { contains: 'image/' } },
35
- {
36
- or: [
37
- { 'imageOptimizer.status': { not_equals: 'complete' } },
38
- { 'imageOptimizer.status': { exists: false } },
39
- ],
40
- },
41
- ],
42
- }
43
-
44
28
  let queued = 0
45
- let page = 1
46
- let hasMore = true
47
-
48
- while (hasMore) {
49
- const result = await req.payload.find({
50
- collection: collectionSlug as CollectionSlug,
51
- limit: 50,
52
- page,
53
- depth: 0,
54
- where,
55
- sort: 'createdAt',
56
- })
57
29
 
58
- for (const doc of result.docs) {
30
+ if (body.docIds && body.docIds.length > 0) {
31
+ // Regenerate specific documents by ID
32
+ for (const docId of body.docIds) {
59
33
  await req.payload.jobs.queue({
60
34
  task: 'imageOptimizer_regenerateDocument',
61
35
  input: {
62
36
  collectionSlug,
63
- docId: String(doc.id),
37
+ docId: String(docId),
64
38
  },
65
39
  })
66
40
  queued++
67
41
  }
42
+ } else {
43
+ // Find all image documents in the collection
44
+ // Unless force=true, skip already-processed docs
45
+ const where: Where = body.force
46
+ ? { mimeType: { contains: 'image/' } }
47
+ : {
48
+ and: [
49
+ { mimeType: { contains: 'image/' } },
50
+ {
51
+ or: [
52
+ { 'imageOptimizer.status': { not_equals: 'complete' } },
53
+ { 'imageOptimizer.status': { exists: false } },
54
+ ],
55
+ },
56
+ ],
57
+ }
58
+
59
+ let page = 1
60
+ let hasMore = true
61
+
62
+ while (hasMore) {
63
+ const result = await req.payload.find({
64
+ collection: collectionSlug as CollectionSlug,
65
+ limit: 50,
66
+ page,
67
+ depth: 0,
68
+ where,
69
+ sort: 'createdAt',
70
+ })
68
71
 
69
- hasMore = result.hasNextPage
70
- page++
72
+ for (const doc of result.docs) {
73
+ await req.payload.jobs.queue({
74
+ task: 'imageOptimizer_regenerateDocument',
75
+ input: {
76
+ collectionSlug,
77
+ docId: String(doc.id),
78
+ },
79
+ })
80
+ queued++
81
+ }
82
+
83
+ hasMore = result.hasNextPage
84
+ page++
85
+ }
71
86
  }
72
87
 
73
88
  req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`)
@@ -1,3 +1,4 @@
1
+ import crypto from 'crypto'
1
2
  import path from 'path'
2
3
  import type { CollectionBeforeChangeHook } from 'payload'
3
4
 
@@ -15,6 +16,16 @@ export const createBeforeChangeHook = (
15
16
 
16
17
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data
17
18
 
19
+ // Rename file to UUID before any processing, so the storage adapter
20
+ // never sees the original filename. Prevents Vercel Blob "already exists"
21
+ // errors and avoids leaking original filenames to storage.
22
+ if (resolvedConfig.uniqueFileNames) {
23
+ const ext = path.extname(req.file.name)
24
+ const uuid = crypto.randomUUID()
25
+ req.file.name = `${uuid}${ext}`
26
+ data.filename = req.file.name
27
+ }
28
+
18
29
  const originalSize = req.file.data.length
19
30
 
20
31
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
@@ -1,3 +1,4 @@
1
+ import crypto from 'crypto'
1
2
  import fs from 'fs/promises'
2
3
  import path from 'path'
3
4
 
@@ -74,6 +75,13 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
74
75
  if (cloudStorage) {
75
76
  // Cloud storage: re-upload the optimized file via Payload's update API.
76
77
  // This triggers the cloud adapter's afterChange hook which uploads to cloud.
78
+ // When uniqueFileNames is enabled, generate a new UUID filename to avoid
79
+ // Vercel Blob "already exists" errors (the adapter doesn't support allowOverwrite).
80
+ if (resolvedConfig.uniqueFileNames) {
81
+ const ext = path.extname(newFilename)
82
+ newFilename = `${crypto.randomUUID()}${ext}`
83
+ }
84
+
77
85
  const updateData: Record<string, any> = {
78
86
  imageOptimizer: {
79
87
  originalSize,
package/src/types.ts CHANGED
@@ -25,6 +25,10 @@ export type ImageOptimizerConfig = {
25
25
  maxDimensions?: { width: number; height: number }
26
26
  replaceOriginal?: boolean
27
27
  stripMetadata?: boolean
28
+ /** Replace original filenames with UUIDs (e.g., `photo.jpg` → `a1b2c3d4.webp`).
29
+ * Prevents Vercel Blob "already exists" errors and avoids leaking original filenames.
30
+ * Defaults to `false`. */
31
+ uniqueFileNames?: boolean
28
32
  }
29
33
 
30
34
  export type ResolvedCollectionOptimizerConfig = {
@@ -40,6 +44,7 @@ export type ResolvedImageOptimizerConfig = Required<
40
44
  collections: ImageOptimizerConfig['collections']
41
45
  disabled: boolean
42
46
  replaceOriginal: boolean
47
+ uniqueFileNames: boolean
43
48
  }
44
49
 
45
50
  export type ImageOptimizerData = {