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

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,11 @@
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;
8
+ const SESSION_KEY = 'imageOptimizer_running';
5
9
  export const RegenerationButton = ()=>{
6
10
  const [isRunning, setIsRunning] = useState(false);
7
11
  const [progress, setProgress] = useState(null);
@@ -44,6 +48,13 @@ export const RegenerationButton = ()=>{
44
48
  intervalRef.current = null;
45
49
  }
46
50
  }, []);
51
+ const startPolling = useCallback((pollFn)=>{
52
+ // Prevent duplicate intervals
53
+ stopPolling();
54
+ intervalRef.current = setInterval(pollFn, POLL_INTERVAL_MS);
55
+ }, [
56
+ stopPolling
57
+ ]);
47
58
  const pollProgress = useCallback(async ()=>{
48
59
  if (!collectionSlug) return;
49
60
  try {
@@ -54,21 +65,24 @@ export const RegenerationButton = ()=>{
54
65
  // Stop polling when no more pending
55
66
  if (data.pending <= 0) {
56
67
  setIsRunning(false);
68
+ setStalled(false);
57
69
  stopPolling();
70
+ sessionStorage.removeItem(SESSION_KEY);
58
71
  return;
59
72
  }
60
- // Stall detection
73
+ // Stall detection — warn but keep polling so we detect when jobs resume
61
74
  const processed = data.complete + data.errored;
62
75
  if (processed === stallRef.current.lastProcessed) {
63
76
  stallRef.current.stallCount += 1;
64
77
  } else {
65
78
  stallRef.current.stallCount = 0;
66
79
  stallRef.current.lastProcessed = processed;
80
+ // Clear stall warning when progress resumes
81
+ setStalled(false);
67
82
  }
68
83
  if (stallRef.current.stallCount >= STALL_THRESHOLD) {
69
- stopPolling();
70
- setIsRunning(false);
71
84
  setStalled(true);
85
+ // Keep polling — jobs may still be running server-side
72
86
  }
73
87
  }
74
88
  } catch {
@@ -78,39 +92,47 @@ export const RegenerationButton = ()=>{
78
92
  collectionSlug,
79
93
  stopPolling
80
94
  ]);
81
- // On mount (once collectionSlug is known), check if there's an ongoing job and resume polling
95
+ // On mount: fetch stats for the counter display. If the user previously
96
+ // triggered regeneration (sessionStorage flag) and there are still pending
97
+ // images, resume polling so the UI reconnects after page navigation.
82
98
  useEffect(()=>{
83
99
  if (!collectionSlug) return;
84
100
  let cancelled = false;
85
- const checkOngoing = async ()=>{
101
+ const loadStats = async ()=>{
86
102
  try {
87
103
  const res = await fetch(`/api/image-optimizer/regenerate?collection=${collectionSlug}`);
88
104
  if (!res.ok || cancelled) return;
89
105
  const data = await res.json();
90
- // Always store stats on mount
91
106
  setStats(data);
92
- if (data.pending > 0) {
107
+ // Resume polling only if the user triggered regeneration in this session
108
+ const wasRunning = sessionStorage.getItem(SESSION_KEY) === collectionSlug;
109
+ if (wasRunning && data.pending > 0) {
93
110
  setProgress(data);
94
111
  setIsRunning(true);
95
112
  setStalled(false);
96
- setQueued(null);
97
113
  stallRef.current = {
98
114
  lastProcessed: data.complete + data.errored,
99
115
  stallCount: 0
100
116
  };
101
- intervalRef.current = setInterval(pollProgress, 2000);
117
+ startPolling(pollProgress);
118
+ } else if (wasRunning && data.pending <= 0) {
119
+ // Jobs finished while we were away — clear the flag
120
+ sessionStorage.removeItem(SESSION_KEY);
102
121
  }
103
122
  } catch {
104
123
  // ignore
105
124
  }
106
125
  };
107
- checkOngoing();
126
+ loadStats();
108
127
  return ()=>{
109
128
  cancelled = true;
129
+ stopPolling();
110
130
  };
111
131
  }, [
112
132
  collectionSlug,
113
- pollProgress
133
+ pollProgress,
134
+ startPolling,
135
+ stopPolling
114
136
  ]);
115
137
  // Refresh stats when regeneration finishes (isRunning transitions from true to false)
116
138
  useEffect(()=>{
@@ -167,8 +189,10 @@ export const RegenerationButton = ()=>{
167
189
  setIsRunning(false);
168
190
  return;
169
191
  }
192
+ // Persist running state so we can resume after page navigation
193
+ sessionStorage.setItem(SESSION_KEY, collectionSlug);
170
194
  // Start polling
171
- intervalRef.current = setInterval(pollProgress, 2000);
195
+ startPolling(pollProgress);
172
196
  } catch (err) {
173
197
  setError(err instanceof Error ? err.message : String(err));
174
198
  setIsRunning(false);
@@ -176,10 +200,10 @@ export const RegenerationButton = ()=>{
176
200
  };
177
201
  // Cleanup interval on unmount
178
202
  useEffect(()=>{
179
- return ()=>{
180
- if (intervalRef.current) clearInterval(intervalRef.current);
181
- };
182
- }, []);
203
+ return ()=>stopPolling();
204
+ }, [
205
+ stopPolling
206
+ ]);
183
207
  if (!collectionSlug) return null;
184
208
  const progressPercent = progress && progress.total > 0 ? Math.round((progress.complete + progress.errored) / progress.total * 100) : 0;
185
209
  const showProgressBar = isRunning && progress || stalled && progress;
@@ -305,13 +329,11 @@ export const RegenerationButton = ()=>{
305
329
  fontSize: '13px'
306
330
  },
307
331
  children: [
308
- "Processing finished with issues. ",
309
- progress.errored + progress.pending,
332
+ "Processing appears slow ",
333
+ progress.pending,
310
334
  " image",
311
- progress.errored + progress.pending !== 1 ? 's' : '',
312
- " failed",
313
- progress.pending > 0 ? ` (${progress.pending} stuck)` : '',
314
- ". Re-run to retry."
335
+ progress.pending !== 1 ? 's' : '',
336
+ " still pending. Jobs may still be running server-side."
315
337
  ]
316
338
  }),
317
339
  showProgressBar && /*#__PURE__*/ _jsxs("div", {
@@ -382,14 +404,14 @@ export const RegenerationButton = ()=>{
382
404
  })
383
405
  ]
384
406
  }),
385
- !isRunning && progress && progress.complete > 0 && queued !== 0 && !confirming && /*#__PURE__*/ _jsxs("span", {
407
+ !isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && !confirming && /*#__PURE__*/ _jsxs("span", {
386
408
  style: {
387
409
  fontSize: '13px'
388
410
  },
389
411
  children: [
390
412
  /*#__PURE__*/ _jsxs("span", {
391
413
  style: {
392
- color: progress.errored > 0 || stalled ? '#f59e0b' : '#10b981'
414
+ color: progress.errored > 0 ? '#f59e0b' : '#10b981'
393
415
  },
394
416
  children: [
395
417
  "Done! ",
@@ -399,19 +421,19 @@ export const RegenerationButton = ()=>{
399
421
  " optimized (across entire collection)."
400
422
  ]
401
423
  }),
402
- (progress.errored > 0 || stalled && progress.pending > 0) && /*#__PURE__*/ _jsxs("span", {
424
+ progress.errored > 0 && /*#__PURE__*/ _jsxs("span", {
403
425
  style: {
404
426
  color: '#ef4444'
405
427
  },
406
428
  children: [
407
429
  ' ',
408
- progress.errored + (stalled ? progress.pending : 0),
430
+ progress.errored,
409
431
  " failed."
410
432
  ]
411
433
  })
412
434
  ]
413
435
  }),
414
- !isRunning && stats && stats.total > 0 && /*#__PURE__*/ _jsxs("div", {
436
+ !isRunning && !stalled && stats && stats.total > 0 && /*#__PURE__*/ _jsxs("div", {
415
437
  style: {
416
438
  marginLeft: 'auto',
417
439
  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\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":"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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.5",
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,11 @@ 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
16
+ const SESSION_KEY = 'imageOptimizer_running'
13
17
 
14
18
  export const RegenerationButton: React.FC = () => {
15
19
  const [isRunning, setIsRunning] = useState(false)
@@ -54,6 +58,15 @@ export const RegenerationButton: React.FC = () => {
54
58
  }
55
59
  }, [])
56
60
 
61
+ const startPolling = useCallback(
62
+ (pollFn: () => void) => {
63
+ // Prevent duplicate intervals
64
+ stopPolling()
65
+ intervalRef.current = setInterval(pollFn, POLL_INTERVAL_MS)
66
+ },
67
+ [stopPolling],
68
+ )
69
+
57
70
  const pollProgress = useCallback(async () => {
58
71
  if (!collectionSlug) return
59
72
  try {
@@ -67,23 +80,26 @@ export const RegenerationButton: React.FC = () => {
67
80
  // Stop polling when no more pending
68
81
  if (data.pending <= 0) {
69
82
  setIsRunning(false)
83
+ setStalled(false)
70
84
  stopPolling()
85
+ sessionStorage.removeItem(SESSION_KEY)
71
86
  return
72
87
  }
73
88
 
74
- // Stall detection
89
+ // Stall detection — warn but keep polling so we detect when jobs resume
75
90
  const processed = data.complete + data.errored
76
91
  if (processed === stallRef.current.lastProcessed) {
77
92
  stallRef.current.stallCount += 1
78
93
  } else {
79
94
  stallRef.current.stallCount = 0
80
95
  stallRef.current.lastProcessed = processed
96
+ // Clear stall warning when progress resumes
97
+ setStalled(false)
81
98
  }
82
99
 
83
100
  if (stallRef.current.stallCount >= STALL_THRESHOLD) {
84
- stopPolling()
85
- setIsRunning(false)
86
101
  setStalled(true)
102
+ // Keep polling — jobs may still be running server-side
87
103
  }
88
104
  }
89
105
  } catch {
@@ -91,36 +107,43 @@ export const RegenerationButton: React.FC = () => {
91
107
  }
92
108
  }, [collectionSlug, stopPolling])
93
109
 
94
- // On mount (once collectionSlug is known), check if there's an ongoing job and resume polling
110
+ // On mount: fetch stats for the counter display. If the user previously
111
+ // triggered regeneration (sessionStorage flag) and there are still pending
112
+ // images, resume polling so the UI reconnects after page navigation.
95
113
  useEffect(() => {
96
114
  if (!collectionSlug) return
97
115
  let cancelled = false
98
- const checkOngoing = async () => {
116
+ const loadStats = async () => {
99
117
  try {
100
118
  const res = await fetch(
101
119
  `/api/image-optimizer/regenerate?collection=${collectionSlug}`,
102
120
  )
103
121
  if (!res.ok || cancelled) return
104
122
  const data: RegenerationProgress = await res.json()
105
- // Always store stats on mount
106
123
  setStats(data)
107
- if (data.pending > 0) {
124
+
125
+ // Resume polling only if the user triggered regeneration in this session
126
+ const wasRunning = sessionStorage.getItem(SESSION_KEY) === collectionSlug
127
+ if (wasRunning && data.pending > 0) {
108
128
  setProgress(data)
109
129
  setIsRunning(true)
110
130
  setStalled(false)
111
- setQueued(null)
112
131
  stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }
113
- intervalRef.current = setInterval(pollProgress, 2000)
132
+ startPolling(pollProgress)
133
+ } else if (wasRunning && data.pending <= 0) {
134
+ // Jobs finished while we were away — clear the flag
135
+ sessionStorage.removeItem(SESSION_KEY)
114
136
  }
115
137
  } catch {
116
138
  // ignore
117
139
  }
118
140
  }
119
- checkOngoing()
141
+ loadStats()
120
142
  return () => {
121
143
  cancelled = true
144
+ stopPolling()
122
145
  }
123
- }, [collectionSlug, pollProgress])
146
+ }, [collectionSlug, pollProgress, startPolling, stopPolling])
124
147
 
125
148
  // Refresh stats when regeneration finishes (isRunning transitions from true to false)
126
149
  useEffect(() => {
@@ -174,8 +197,10 @@ export const RegenerationButton: React.FC = () => {
174
197
  return
175
198
  }
176
199
 
200
+ // Persist running state so we can resume after page navigation
201
+ sessionStorage.setItem(SESSION_KEY, collectionSlug)
177
202
  // Start polling
178
- intervalRef.current = setInterval(pollProgress, 2000)
203
+ startPolling(pollProgress)
179
204
  } catch (err) {
180
205
  setError(err instanceof Error ? err.message : String(err))
181
206
  setIsRunning(false)
@@ -184,10 +209,8 @@ export const RegenerationButton: React.FC = () => {
184
209
 
185
210
  // Cleanup interval on unmount
186
211
  useEffect(() => {
187
- return () => {
188
- if (intervalRef.current) clearInterval(intervalRef.current)
189
- }
190
- }, [])
212
+ return () => stopPolling()
213
+ }, [stopPolling])
191
214
 
192
215
  if (!collectionSlug) return null
193
216
 
@@ -307,10 +330,8 @@ export const RegenerationButton: React.FC = () => {
307
330
 
308
331
  {stalled && progress && (
309
332
  <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.
333
+ Processing appears slow {progress.pending} image{progress.pending !== 1 ? 's' : ''} still pending.
334
+ Jobs may still be running server-side.
314
335
  </span>
315
336
  )}
316
337
 
@@ -363,21 +384,21 @@ export const RegenerationButton: React.FC = () => {
363
384
  </div>
364
385
  )}
365
386
 
366
- {!isRunning && progress && progress.complete > 0 && queued !== 0 && !confirming && (
387
+ {!isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && !confirming && (
367
388
  <span style={{ fontSize: '13px' }}>
368
- <span style={{ color: progress.errored > 0 || stalled ? '#f59e0b' : '#10b981' }}>
389
+ <span style={{ color: progress.errored > 0 ? '#f59e0b' : '#10b981' }}>
369
390
  Done! {progress.complete}/{progress.total} optimized (across entire collection).
370
391
  </span>
371
- {(progress.errored > 0 || (stalled && progress.pending > 0)) && (
392
+ {progress.errored > 0 && (
372
393
  <span style={{ color: '#ef4444' }}>
373
- {' '}{progress.errored + (stalled ? progress.pending : 0)} failed.
394
+ {' '}{progress.errored} failed.
374
395
  </span>
375
396
  )}
376
397
  </span>
377
398
  )}
378
399
 
379
400
  {/* Persistent optimization stats — always visible when not actively regenerating */}
380
- {!isRunning && stats && stats.total > 0 && (
401
+ {!isRunning && !stalled && stats && stats.total > 0 && (
381
402
  <div
382
403
  style={{
383
404
  marginLeft: 'auto',