@inoo-ch/payload-image-optimizer 1.4.2 → 1.4.3

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.
@@ -1,7 +1,10 @@
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
- const STALL_THRESHOLD = 5;
4
+ const POLL_INTERVAL_MS = 2000;
5
+ // With sequential processing each image takes ~4-5s, so no progress for 30s
6
+ // (15 polls) strongly suggests a real stall rather than slow processing.
7
+ const STALL_THRESHOLD = 15;
5
8
  export const RegenerationButton = ()=>{
6
9
  const [isRunning, setIsRunning] = useState(false);
7
10
  const [progress, setProgress] = useState(null);
@@ -44,6 +47,13 @@ export const RegenerationButton = ()=>{
44
47
  intervalRef.current = null;
45
48
  }
46
49
  }, []);
50
+ const startPolling = useCallback((pollFn)=>{
51
+ // Prevent duplicate intervals
52
+ stopPolling();
53
+ intervalRef.current = setInterval(pollFn, POLL_INTERVAL_MS);
54
+ }, [
55
+ stopPolling
56
+ ]);
47
57
  const pollProgress = useCallback(async ()=>{
48
58
  if (!collectionSlug) return;
49
59
  try {
@@ -54,21 +64,23 @@ export const RegenerationButton = ()=>{
54
64
  // Stop polling when no more pending
55
65
  if (data.pending <= 0) {
56
66
  setIsRunning(false);
67
+ setStalled(false);
57
68
  stopPolling();
58
69
  return;
59
70
  }
60
- // Stall detection
71
+ // Stall detection — warn but keep polling so we detect when jobs resume
61
72
  const processed = data.complete + data.errored;
62
73
  if (processed === stallRef.current.lastProcessed) {
63
74
  stallRef.current.stallCount += 1;
64
75
  } else {
65
76
  stallRef.current.stallCount = 0;
66
77
  stallRef.current.lastProcessed = processed;
78
+ // Clear stall warning when progress resumes
79
+ setStalled(false);
67
80
  }
68
81
  if (stallRef.current.stallCount >= STALL_THRESHOLD) {
69
- stopPolling();
70
- setIsRunning(false);
71
82
  setStalled(true);
83
+ // Keep polling — jobs may still be running server-side
72
84
  }
73
85
  }
74
86
  } catch {
@@ -98,7 +110,7 @@ export const RegenerationButton = ()=>{
98
110
  lastProcessed: data.complete + data.errored,
99
111
  stallCount: 0
100
112
  };
101
- intervalRef.current = setInterval(pollProgress, 2000);
113
+ startPolling(pollProgress);
102
114
  }
103
115
  } catch {
104
116
  // ignore
@@ -107,10 +119,14 @@ export const RegenerationButton = ()=>{
107
119
  checkOngoing();
108
120
  return ()=>{
109
121
  cancelled = true;
122
+ // Clear interval on effect cleanup to prevent duplicate intervals
123
+ stopPolling();
110
124
  };
111
125
  }, [
112
126
  collectionSlug,
113
- pollProgress
127
+ pollProgress,
128
+ startPolling,
129
+ stopPolling
114
130
  ]);
115
131
  // Refresh stats when regeneration finishes (isRunning transitions from true to false)
116
132
  useEffect(()=>{
@@ -168,7 +184,7 @@ export const RegenerationButton = ()=>{
168
184
  return;
169
185
  }
170
186
  // Start polling
171
- intervalRef.current = setInterval(pollProgress, 2000);
187
+ startPolling(pollProgress);
172
188
  } catch (err) {
173
189
  setError(err instanceof Error ? err.message : String(err));
174
190
  setIsRunning(false);
@@ -176,10 +192,10 @@ export const RegenerationButton = ()=>{
176
192
  };
177
193
  // Cleanup interval on unmount
178
194
  useEffect(()=>{
179
- return ()=>{
180
- if (intervalRef.current) clearInterval(intervalRef.current);
181
- };
182
- }, []);
195
+ return ()=>stopPolling();
196
+ }, [
197
+ stopPolling
198
+ ]);
183
199
  if (!collectionSlug) return null;
184
200
  const progressPercent = progress && progress.total > 0 ? Math.round((progress.complete + progress.errored) / progress.total * 100) : 0;
185
201
  const showProgressBar = isRunning && progress || stalled && progress;
@@ -305,13 +321,11 @@ export const RegenerationButton = ()=>{
305
321
  fontSize: '13px'
306
322
  },
307
323
  children: [
308
- "Processing finished with issues. ",
309
- progress.errored + progress.pending,
324
+ "Processing appears slow ",
325
+ progress.pending,
310
326
  " image",
311
- progress.errored + progress.pending !== 1 ? 's' : '',
312
- " failed",
313
- progress.pending > 0 ? ` (${progress.pending} stuck)` : '',
314
- ". Re-run to retry."
327
+ progress.pending !== 1 ? 's' : '',
328
+ " still pending. Jobs may still be running server-side."
315
329
  ]
316
330
  }),
317
331
  showProgressBar && /*#__PURE__*/ _jsxs("div", {
@@ -382,14 +396,14 @@ export const RegenerationButton = ()=>{
382
396
  })
383
397
  ]
384
398
  }),
385
- !isRunning && progress && progress.complete > 0 && queued !== 0 && !confirming && /*#__PURE__*/ _jsxs("span", {
399
+ !isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && !confirming && /*#__PURE__*/ _jsxs("span", {
386
400
  style: {
387
401
  fontSize: '13px'
388
402
  },
389
403
  children: [
390
404
  /*#__PURE__*/ _jsxs("span", {
391
405
  style: {
392
- color: progress.errored > 0 || stalled ? '#f59e0b' : '#10b981'
406
+ color: progress.errored > 0 ? '#f59e0b' : '#10b981'
393
407
  },
394
408
  children: [
395
409
  "Done! ",
@@ -399,19 +413,19 @@ export const RegenerationButton = ()=>{
399
413
  " optimized (across entire collection)."
400
414
  ]
401
415
  }),
402
- (progress.errored > 0 || stalled && progress.pending > 0) && /*#__PURE__*/ _jsxs("span", {
416
+ progress.errored > 0 && /*#__PURE__*/ _jsxs("span", {
403
417
  style: {
404
418
  color: '#ef4444'
405
419
  },
406
420
  children: [
407
421
  ' ',
408
- progress.errored + (stalled ? progress.pending : 0),
422
+ progress.errored,
409
423
  " failed."
410
424
  ]
411
425
  })
412
426
  ]
413
427
  }),
414
- !isRunning && stats && stats.total > 0 && /*#__PURE__*/ _jsxs("div", {
428
+ !isRunning && !stalled && stats && stats.total > 0 && /*#__PURE__*/ _jsxs("div", {
415
429
  style: {
416
430
  marginLeft: 'auto',
417
431
  display: 'flex',
@@ -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 STALL_THRESHOLD = 5\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 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 stopPolling()\n return\n }\n\n // Stall detection\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 }\n\n if (stallRef.current.stallCount >= STALL_THRESHOLD) {\n stopPolling()\n setIsRunning(false)\n setStalled(true)\n }\n }\n } catch {\n // ignore polling errors\n }\n }, [collectionSlug, stopPolling])\n\n // On mount (once collectionSlug is known), check if there's an ongoing job and resume polling\n useEffect(() => {\n if (!collectionSlug) return\n let cancelled = false\n const checkOngoing = 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 // Always store stats on mount\n setStats(data)\n if (data.pending > 0) {\n setProgress(data)\n setIsRunning(true)\n setStalled(false)\n setQueued(null)\n stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }\n intervalRef.current = setInterval(pollProgress, 2000)\n }\n } catch {\n // ignore\n }\n }\n checkOngoing()\n return () => {\n cancelled = true\n }\n }, [collectionSlug, pollProgress])\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 // Start polling\n intervalRef.current = setInterval(pollProgress, 2000)\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 () => {\n if (intervalRef.current) clearInterval(intervalRef.current)\n }\n }, [])\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 finished with issues. {progress.errored + progress.pending} image\n {progress.errored + progress.pending !== 1 ? 's' : ''} failed\n {progress.pending > 0 ? ` (${progress.pending} stuck)` : ''}.\n Re-run to retry.\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 && progress && progress.complete > 0 && queued !== 0 && !confirming && (\n <span style={{ fontSize: '13px' }}>\n <span style={{ color: progress.errored > 0 || stalled ? '#f59e0b' : '#10b981' }}>\n Done! {progress.complete}/{progress.total} optimized (across entire collection).\n </span>\n {(progress.errored > 0 || (stalled && progress.pending > 0)) && (\n <span style={{ color: '#ef4444' }}>\n {' '}{progress.errored + (stalled ? progress.pending : 0)} failed.\n </span>\n )}\n </span>\n )}\n\n {/* Persistent optimization stats — always visible when not actively regenerating */}\n {!isRunning && 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","STALL_THRESHOLD","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","pollProgress","pending","processed","complete","errored","cancelled","checkOngoing","setInterval","handlePreflight","handleCancel","handleConfirm","method","headers","body","JSON","stringify","Error","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":"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"}
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\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 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 (once collectionSlug is known), check if there's an ongoing job and resume polling\n useEffect(() => {\n if (!collectionSlug) return\n let cancelled = false\n const checkOngoing = 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 // Always store stats on mount\n setStats(data)\n if (data.pending > 0) {\n setProgress(data)\n setIsRunning(true)\n setStalled(false)\n setQueued(null)\n stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }\n startPolling(pollProgress)\n }\n } catch {\n // ignore\n }\n }\n checkOngoing()\n return () => {\n cancelled = true\n // Clear interval on effect cleanup to prevent duplicate intervals\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 // 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","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","processed","complete","errored","cancelled","checkOngoing","handlePreflight","handleCancel","handleConfirm","method","headers","body","JSON","stringify","Error","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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
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": [
@@ -9,7 +9,10 @@ type RegenerationProgress = {
9
9
  pending: number
10
10
  }
11
11
 
12
- const STALL_THRESHOLD = 5
12
+ const POLL_INTERVAL_MS = 2000
13
+ // With sequential processing each image takes ~4-5s, so no progress for 30s
14
+ // (15 polls) strongly suggests a real stall rather than slow processing.
15
+ const STALL_THRESHOLD = 15
13
16
 
14
17
  export const RegenerationButton: React.FC = () => {
15
18
  const [isRunning, setIsRunning] = useState(false)
@@ -54,6 +57,15 @@ export const RegenerationButton: React.FC = () => {
54
57
  }
55
58
  }, [])
56
59
 
60
+ const startPolling = useCallback(
61
+ (pollFn: () => void) => {
62
+ // Prevent duplicate intervals
63
+ stopPolling()
64
+ intervalRef.current = setInterval(pollFn, POLL_INTERVAL_MS)
65
+ },
66
+ [stopPolling],
67
+ )
68
+
57
69
  const pollProgress = useCallback(async () => {
58
70
  if (!collectionSlug) return
59
71
  try {
@@ -67,23 +79,25 @@ export const RegenerationButton: React.FC = () => {
67
79
  // Stop polling when no more pending
68
80
  if (data.pending <= 0) {
69
81
  setIsRunning(false)
82
+ setStalled(false)
70
83
  stopPolling()
71
84
  return
72
85
  }
73
86
 
74
- // Stall detection
87
+ // Stall detection — warn but keep polling so we detect when jobs resume
75
88
  const processed = data.complete + data.errored
76
89
  if (processed === stallRef.current.lastProcessed) {
77
90
  stallRef.current.stallCount += 1
78
91
  } else {
79
92
  stallRef.current.stallCount = 0
80
93
  stallRef.current.lastProcessed = processed
94
+ // Clear stall warning when progress resumes
95
+ setStalled(false)
81
96
  }
82
97
 
83
98
  if (stallRef.current.stallCount >= STALL_THRESHOLD) {
84
- stopPolling()
85
- setIsRunning(false)
86
99
  setStalled(true)
100
+ // Keep polling — jobs may still be running server-side
87
101
  }
88
102
  }
89
103
  } catch {
@@ -110,7 +124,7 @@ export const RegenerationButton: React.FC = () => {
110
124
  setStalled(false)
111
125
  setQueued(null)
112
126
  stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }
113
- intervalRef.current = setInterval(pollProgress, 2000)
127
+ startPolling(pollProgress)
114
128
  }
115
129
  } catch {
116
130
  // ignore
@@ -119,8 +133,10 @@ export const RegenerationButton: React.FC = () => {
119
133
  checkOngoing()
120
134
  return () => {
121
135
  cancelled = true
136
+ // Clear interval on effect cleanup to prevent duplicate intervals
137
+ stopPolling()
122
138
  }
123
- }, [collectionSlug, pollProgress])
139
+ }, [collectionSlug, pollProgress, startPolling, stopPolling])
124
140
 
125
141
  // Refresh stats when regeneration finishes (isRunning transitions from true to false)
126
142
  useEffect(() => {
@@ -175,7 +191,7 @@ export const RegenerationButton: React.FC = () => {
175
191
  }
176
192
 
177
193
  // Start polling
178
- intervalRef.current = setInterval(pollProgress, 2000)
194
+ startPolling(pollProgress)
179
195
  } catch (err) {
180
196
  setError(err instanceof Error ? err.message : String(err))
181
197
  setIsRunning(false)
@@ -184,10 +200,8 @@ export const RegenerationButton: React.FC = () => {
184
200
 
185
201
  // Cleanup interval on unmount
186
202
  useEffect(() => {
187
- return () => {
188
- if (intervalRef.current) clearInterval(intervalRef.current)
189
- }
190
- }, [])
203
+ return () => stopPolling()
204
+ }, [stopPolling])
191
205
 
192
206
  if (!collectionSlug) return null
193
207
 
@@ -307,10 +321,8 @@ export const RegenerationButton: React.FC = () => {
307
321
 
308
322
  {stalled && progress && (
309
323
  <span style={{ color: '#f59e0b', fontSize: '13px' }}>
310
- Processing finished with issues. {progress.errored + progress.pending} image
311
- {progress.errored + progress.pending !== 1 ? 's' : ''} failed
312
- {progress.pending > 0 ? ` (${progress.pending} stuck)` : ''}.
313
- Re-run to retry.
324
+ Processing appears slow {progress.pending} image{progress.pending !== 1 ? 's' : ''} still pending.
325
+ Jobs may still be running server-side.
314
326
  </span>
315
327
  )}
316
328
 
@@ -363,21 +375,21 @@ export const RegenerationButton: React.FC = () => {
363
375
  </div>
364
376
  )}
365
377
 
366
- {!isRunning && progress && progress.complete > 0 && queued !== 0 && !confirming && (
378
+ {!isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && !confirming && (
367
379
  <span style={{ fontSize: '13px' }}>
368
- <span style={{ color: progress.errored > 0 || stalled ? '#f59e0b' : '#10b981' }}>
380
+ <span style={{ color: progress.errored > 0 ? '#f59e0b' : '#10b981' }}>
369
381
  Done! {progress.complete}/{progress.total} optimized (across entire collection).
370
382
  </span>
371
- {(progress.errored > 0 || (stalled && progress.pending > 0)) && (
383
+ {progress.errored > 0 && (
372
384
  <span style={{ color: '#ef4444' }}>
373
- {' '}{progress.errored + (stalled ? progress.pending : 0)} failed.
385
+ {' '}{progress.errored} failed.
374
386
  </span>
375
387
  )}
376
388
  </span>
377
389
  )}
378
390
 
379
391
  {/* Persistent optimization stats — always visible when not actively regenerating */}
380
- {!isRunning && stats && stats.total > 0 && (
392
+ {!isRunning && !stalled && stats && stats.total > 0 && (
381
393
  <div
382
394
  style={{
383
395
  marginLeft: 'auto',