@inoo-ch/payload-image-optimizer 1.4.1 → 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.
- package/dist/components/RegenerationButton.js +36 -22
- package/dist/components/RegenerationButton.js.map +1 -1
- package/dist/endpoints/regenerate.js +2 -1
- package/dist/endpoints/regenerate.js.map +1 -1
- package/dist/hooks/afterChange.js +3 -1
- package/dist/hooks/afterChange.js.map +1 -1
- package/package.json +1 -1
- package/src/components/RegenerationButton.tsx +32 -20
- package/src/endpoints/regenerate.ts +1 -1
- package/src/hooks/afterChange.ts +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
309
|
-
progress.
|
|
324
|
+
"Processing appears slow — ",
|
|
325
|
+
progress.pending,
|
|
310
326
|
" image",
|
|
311
|
-
progress.
|
|
312
|
-
"
|
|
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
|
|
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
|
-
|
|
416
|
+
progress.errored > 0 && /*#__PURE__*/ _jsxs("span", {
|
|
403
417
|
style: {
|
|
404
418
|
color: '#ef4444'
|
|
405
419
|
},
|
|
406
420
|
children: [
|
|
407
421
|
' ',
|
|
408
|
-
progress.errored
|
|
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 ✓ 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' }}>·</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":"AAAA;;AAEA,OAAOA,SAASC,QAAQ,EAAEC,SAAS,EAAEC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AASvE,MAAMC,kBAAkB;AAExB,OAAO,MAAMC,qBAA+B;IAC1C,MAAM,CAACC,WAAWC,aAAa,GAAGP,SAAS;IAC3C,MAAM,CAACQ,UAAUC,YAAY,GAAGT,SAAsC;IACtE,MAAM,CAACU,QAAQC,UAAU,GAAGX,SAAwB;IACpD,MAAM,CAACY,OAAOC,SAAS,GAAGb,SAAS;IACnC,MAAM,CAACc,OAAOC,SAAS,GAAGf,SAAwB;IAClD,MAAM,CAACgB,SAASC,WAAW,GAAGjB,SAAS;IACvC,MAAM,CAACkB,gBAAgBC,kBAAkB,GAAGnB,SAAwB;IACpE,MAAM,CAACoB,OAAOC,SAAS,GAAGrB,SAAsC;IAChE,MAAM,CAACsB,YAAYC,cAAc,GAAGvB,SAAS;IAC7C,MAAMwB,cAAcrB,OAA8C;IAClE,MAAMsB,WAAWtB,OAAO;QAAEuB,eAAe;QAAGC,YAAY;IAAE;IAC1D,MAAMC,mBAAmBzB,OAAO;IAEhC,2EAA2E;IAC3EF,UAAU;QACR,MAAM4B,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,aAAahC,YAAY;QAC7B,IAAI,CAACgB,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,cAActC,YAAY;QAC9B,IAAIsB,YAAYiB,OAAO,EAAE;YACvBC,cAAclB,YAAYiB,OAAO;YACjCjB,YAAYiB,OAAO,GAAG;QACxB;IACF,GAAG,EAAE;IAEL,MAAME,eAAezC,YAAY;QAC/B,IAAI,CAACgB,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,KAAKM,OAAO,IAAI,GAAG;oBACrBrC,aAAa;oBACbiC;oBACA;gBACF;gBAEA,kBAAkB;gBAClB,MAAMK,YAAYP,KAAKQ,QAAQ,GAAGR,KAAKS,OAAO;gBAC9C,IAAIF,cAAcpB,SAASgB,OAAO,CAACf,aAAa,EAAE;oBAChDD,SAASgB,OAAO,CAACd,UAAU,IAAI;gBACjC,OAAO;oBACLF,SAASgB,OAAO,CAACd,UAAU,GAAG;oBAC9BF,SAASgB,OAAO,CAACf,aAAa,GAAGmB;gBACnC;gBAEA,IAAIpB,SAASgB,OAAO,CAACd,UAAU,IAAIvB,iBAAiB;oBAClDoC;oBACAjC,aAAa;oBACbU,WAAW;gBACb;YACF;QACF,EAAE,OAAM;QACN,wBAAwB;QAC1B;IACF,GAAG;QAACC;QAAgBsB;KAAY;IAEhC,8FAA8F;IAC9FvC,UAAU;QACR,IAAI,CAACiB,gBAAgB;QACrB,IAAI8B,YAAY;QAChB,MAAMC,eAAe;YACnB,IAAI;gBACF,MAAMd,MAAM,MAAMC,MAChB,CAAC,2CAA2C,EAAElB,gBAAgB;gBAEhE,IAAI,CAACiB,IAAIE,EAAE,IAAIW,WAAW;gBAC1B,MAAMV,OAA6B,MAAMH,IAAII,IAAI;gBACjD,8BAA8B;gBAC9BlB,SAASiB;gBACT,IAAIA,KAAKM,OAAO,GAAG,GAAG;oBACpBnC,YAAY6B;oBACZ/B,aAAa;oBACbU,WAAW;oBACXN,UAAU;oBACVc,SAASgB,OAAO,GAAG;wBAAEf,eAAeY,KAAKQ,QAAQ,GAAGR,KAAKS,OAAO;wBAAEpB,YAAY;oBAAE;oBAChFH,YAAYiB,OAAO,GAAGS,YAAYP,cAAc;gBAClD;YACF,EAAE,OAAM;YACN,SAAS;YACX;QACF;QACAM;QACA,OAAO;YACLD,YAAY;QACd;IACF,GAAG;QAAC9B;QAAgByB;KAAa;IAEjC,sFAAsF;IACtF1C,UAAU;QACR,IAAI2B,iBAAiBa,OAAO,IAAI,CAACnC,WAAW;YAC1C4B;QACF;QACAN,iBAAiBa,OAAO,GAAGnC;IAC7B,GAAG;QAACA;QAAW4B;KAAW;IAE1B,yCAAyC;IACzC,MAAMiB,kBAAkB;QACtB,IAAI,CAACjC,gBAAgB;QACrBH,SAAS;QACT,2DAA2D;QAC3D,MAAMmB;QACNX,cAAc;IAChB;IAEA,MAAM6B,eAAe;QACnB7B,cAAc;IAChB;IAEA,6DAA6D;IAC7D,MAAM8B,gBAAgB;QACpB,IAAI,CAACnC,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;gBACzDkB,QAAQ;gBACRC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBAAExC;oBAAgBN;gBAAM;YAC/C;YAEA,IAAI,CAACuB,IAAIE,EAAE,EAAE;gBACX,MAAMC,OAAO,MAAMH,IAAII,IAAI;gBAC3B,MAAM,IAAIoB,MAAMrB,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,gBAAgB;YAChBiB,YAAYiB,OAAO,GAAGS,YAAYP,cAAc;QAClD,EAAE,OAAOiB,KAAK;YACZ7C,SAAS6C,eAAeD,QAAQC,IAAIC,OAAO,GAAGC,OAAOF;YACrDrD,aAAa;QACf;IACF;IAEA,8BAA8B;IAC9BN,UAAU;QACR,OAAO;YACL,IAAIuB,YAAYiB,OAAO,EAAEC,cAAclB,YAAYiB,OAAO;QAC5D;IACF,GAAG,EAAE;IAEL,IAAI,CAACvB,gBAAgB,OAAO;IAE5B,MAAM6C,kBACJvD,YAAYA,SAASwD,KAAK,GAAG,IACzBC,KAAKC,KAAK,CAAC,AAAE1D,CAAAA,SAASsC,QAAQ,GAAGtC,SAASuC,OAAO,AAAD,IAAKvC,SAASwD,KAAK,GAAI,OACvE;IAEN,MAAMG,kBAAkB,AAAC7D,aAAaE,YAAcQ,WAAWR;IAE/D,qBAAqB;IACrB,MAAM4D,eACJhD,SAASA,MAAM4C,KAAK,GAAG,IACnBC,KAAKC,KAAK,CAAC,AAAC9C,MAAM0B,QAAQ,GAAG1B,MAAM4C,KAAK,GAAI,OAC5C;IACN,MAAMK,eAAejD,SAASA,MAAM4C,KAAK,GAAG,KAAK5C,MAAM0B,QAAQ,KAAK1B,MAAM4C,KAAK;IAE/E,qBACE,MAACM;QACCC,OAAO;YACLC,SAAS;YACTC,cAAc;YACdC,SAAS;YACTC,YAAY;YACZC,KAAK;YACLC,UAAU;QACZ;;YAEC,CAACvD,4BACA,KAACwD;gBACCC,SAAS5B;gBACT6B,UAAU1E;gBACViE,OAAO;oBACLU,iBAAiB3E,YAAY,YAAY;oBACzC4E,OAAO;oBACPC,QAAQ;oBACRC,cAAc;oBACdZ,SAAS;oBACTa,UAAU;oBACVC,YAAY;oBACZC,QAAQjF,YAAY,gBAAgB;gBACtC;0BAECA,YAAY,6BAA6B;;YAI7CgB,cAAcF,uBACb,MAACkD;gBAAIC,OAAO;oBAAEG,SAAS;oBAAQC,YAAY;oBAAUC,KAAK;gBAAO;;kCAC/D,KAACY;wBAAKjB,OAAO;4BAAEc,UAAU;4BAAQH,OAAO;wBAAU;kCAC/CtE,QACG,CAAC,eAAe,EAAEQ,MAAM4C,KAAK,CAAC,qCAAqC,CAAC,GACpE,CAAC,WAAW,EAAE5C,MAAMwB,OAAO,CAAC,kBAAkB,EAAExB,MAAMwB,OAAO,KAAK,IAAI,MAAM,GAAG,8BAA8B,CAAC;;kCAEpH,KAACkC;wBACCC,SAAS1B;wBACTkB,OAAO;4BACLU,iBAAiB;4BACjBC,OAAO;4BACPC,QAAQ;4BACRC,cAAc;4BACdZ,SAAS;4BACTa,UAAU;4BACVC,YAAY;4BACZC,QAAQ;wBACV;kCACD;;kCAGD,KAACT;wBACCC,SAAS3B;wBACTmB,OAAO;4BACLU,iBAAiB;4BACjBC,OAAO;4BACPC,QAAQ;4BACRC,cAAc;4BACdZ,SAAS;4BACTa,UAAU;4BACVC,YAAY;4BACZC,QAAQ;wBACV;kCACD;;;;YAMJ,CAACjE,4BACA,MAACmE;gBACClB,OAAO;oBAAEG,SAAS;oBAAQC,YAAY;oBAAUC,KAAK;oBAAOS,UAAU;gBAAO;;kCAE7E,KAACK;wBACCC,MAAK;wBACLC,SAAShF;wBACTiF,UAAU,CAACC,IAAMjF,SAASiF,EAAEC,MAAM,CAACH,OAAO;wBAC1CZ,UAAU1E;;oBACV;;;YAKLQ,uBACC,KAAC0E;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;0BAAIvE;;YAGvDJ,WAAW,QAAQA,SAAS,KAAKJ,aAAa,CAACgB,4BAC9C,MAACkE;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;;oBAAG;oBAC3C3E;oBAAO;oBAAOA,WAAW,IAAI,MAAM;oBAAG;;;YAIjDA,WAAW,KAAK,CAACJ,aAAa,CAACU,WAAW,CAACM,4BAC1C,KAACkE;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;0BAAG;;YAKtDrE,WAAWR,0BACV,MAACgF;gBAAKjB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;;oBAAG;oBACjB7E,SAASuC,OAAO,GAAGvC,SAASoC,OAAO;oBAAC;oBACrEpC,SAASuC,OAAO,GAAGvC,SAASoC,OAAO,KAAK,IAAI,MAAM;oBAAG;oBACrDpC,SAASoC,OAAO,GAAG,IAAI,CAAC,EAAE,EAAEpC,SAASoC,OAAO,CAAC,OAAO,CAAC,GAAG;oBAAG;;;YAK/DuB,iCACC,MAACG;gBAAIC,OAAO;oBAAEyB,MAAM;oBAAGC,UAAU;gBAAQ;;kCACvC,MAAC3B;wBACCC,OAAO;4BACLG,SAAS;4BACTwB,gBAAgB;4BAChBb,UAAU;4BACVc,cAAc;wBAChB;;0CAEA,MAACX;;oCACEhF,SAASsC,QAAQ;oCAAC;oCAAItC,SAASwD,KAAK;oCAAC;;;4BAEvCxD,SAASuC,OAAO,GAAG,mBAClB,MAACyC;gCAAKjB,OAAO;oCAAEW,OAAO;gCAAU;;oCAAI1E,SAASuC,OAAO;oCAAC;;;0CAEvD,MAACyC;;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,GAAG9F,SAASwD,KAAK,GAAG,IAAIC,KAAKC,KAAK,CAAC,AAAC1D,SAASsC,QAAQ,GAAGtC,SAASwD,KAAK,GAAI,OAAO,EAAE,CAAC,CAAC;oCAC5FiB,iBAAiB;oCACjBsB,YAAY;gCACd;;4BAED/F,SAASuC,OAAO,GAAG,mBAClB,KAACuB;gCACCC,OAAO;oCACL6B,QAAQ;oCACRE,OAAO,GAAG9F,SAASwD,KAAK,GAAG,IAAIC,KAAKC,KAAK,CAAC,AAAC1D,SAASuC,OAAO,GAAGvC,SAASwD,KAAK,GAAI,OAAO,EAAE,CAAC,CAAC;oCAC3FiB,iBAAiB;oCACjBsB,YAAY;gCACd;;;;;;YAOT,CAACjG,aAAaE,YAAYA,SAASsC,QAAQ,GAAG,KAAKpC,WAAW,KAAK,CAACY,4BACnE,MAACkE;gBAAKjB,OAAO;oBAAEc,UAAU;gBAAO;;kCAC9B,MAACG;wBAAKjB,OAAO;4BAAEW,OAAO1E,SAASuC,OAAO,GAAG,KAAK/B,UAAU,YAAY;wBAAU;;4BAAG;4BACxER,SAASsC,QAAQ;4BAAC;4BAAEtC,SAASwD,KAAK;4BAAC;;;oBAE1CxD,CAAAA,SAASuC,OAAO,GAAG,KAAM/B,WAAWR,SAASoC,OAAO,GAAG,CAAC,mBACxD,MAAC4C;wBAAKjB,OAAO;4BAAEW,OAAO;wBAAU;;4BAC7B;4BAAK1E,SAASuC,OAAO,GAAI/B,CAAAA,UAAUR,SAASoC,OAAO,GAAG,CAAA;4BAAG;;;;;YAOjE,CAACtC,aAAac,SAASA,MAAM4C,KAAK,GAAG,mBACpC,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;gCACnB9D,MAAM4C,KAAK;gCAAC;;2CAG5B;;8CACE,MAACwB;oCAAKjB,OAAO;wCAAEW,OAAO;oCAAU;;wCAC7B9D,MAAM0B,QAAQ;wCAAC;wCAAE1B,MAAM4C,KAAK;wCAAC;;;gCAE/B5C,MAAM2B,OAAO,GAAG,mBACf;;sDACE,KAACyC;4CAAKjB,OAAO;gDAAEW,OAAO;4CAAU;sDAAG;;sDACnC,MAACM;4CAAKjB,OAAO;gDAAEW,OAAO;4CAAU;;gDAAI9D,MAAM2B,OAAO;gDAAC;;;;;;;;oBAM3D,CAACsB,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,iBAAiB7D,MAAM2B,OAAO,GAAG,IAAI,YAAY;gCACjDqC,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'\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 ✓ 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' }}>·</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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"}
|
|
@@ -81,7 +81,8 @@ export const createRegenerateHandler = (resolvedConfig)=>{
|
|
|
81
81
|
// after the response is sent, so jobs actually complete on Vercel/serverless.
|
|
82
82
|
if (queued > 0) {
|
|
83
83
|
const runPromise = req.payload.jobs.run({
|
|
84
|
-
limit: queued
|
|
84
|
+
limit: queued,
|
|
85
|
+
sequential: true
|
|
85
86
|
}).catch((err)=>{
|
|
86
87
|
req.payload.logger.error({
|
|
87
88
|
err
|
|
@@ -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 }).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","catch","err","createRegenerateStatusHandler","url","URL","searchParams","get","total","count","complete","equals","errored","totalDocs","pending"],"mappings":"AAIA,SAASA,SAAS,QAAQ,4BAA2B;AAErD,OAAO,MAAMC,0BAA0B,CAACC;IACtC,MAAMC,UAA0B,OAAOC;QACrC,IAAI,CAACA,IAAIC,IAAI,EAAE;YACb,OAAOC,SAASC,IAAI,CAAC;gBAAEC,OAAO;YAAe,GAAG;gBAAEC,QAAQ;YAAI;QAChE;QAEA,IAAIC;QACJ,IAAI;YACFA,OAAO,MAAMN,IAAIG,IAAI;QACvB,EAAE,OAAM;YACNG,OAAO,CAAC;QACV;QAEA,MAAMC,iBAAiBD,KAAKC,cAAc;QAC1C,IAAI,CAACA,kBAAkB,CAACT,eAAeU,WAAW,CAACD,eAAiC,EAAE;YACpF,OAAOL,SAASC,IAAI,CAClB;gBAAEC,OAAO;YAA0C,GACnD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,6CAA6C;QAC7C,iDAAiD;QACjD,MAAMI,QAAeH,KAAKI,KAAK,GAC3B;YAAEC,UAAU;gBAAEC,UAAU;YAAS;QAAE,IACnC;YACEC,KAAK;gBACH;oBAAEF,UAAU;wBAAEC,UAAU;oBAAS;gBAAE;gBACnC;oBACEE,IAAI;wBACF;4BAAE,yBAAyB;gCAAEC,YAAY;4BAAW;wBAAE;wBACtD;4BAAE,yBAAyB;gCAAEC,QAAQ;4BAAM;wBAAE;qBAC9C;gBACH;aACD;QACH;QAEJ,IAAIC,SAAS;QACb,IAAIC,OAAO;QACX,IAAIC,UAAU;QAEd,MAAOA,QAAS;YACd,MAAMC,SAAS,MAAMpB,IAAIqB,OAAO,CAACC,IAAI,CAAC;gBACpCC,YAAYhB;gBACZiB,OAAO;gBACPN;gBACAO,OAAO;gBACPhB;gBACAiB,MAAM;YACR;YAEA,KAAK,MAAMC,OAAOP,OAAOQ,IAAI,CAAE;gBAC7B,MAAM5B,IAAIqB,OAAO,CAACQ,IAAI,CAACC,KAAK,CAAC;oBAC3BC,MAAM;oBACNC,OAAO;wBACLzB;wBACA0B,OAAOC,OAAOP,IAAIQ,EAAE;oBACtB;gBACF;gBACAlB;YACF;YAEAE,UAAUC,OAAOgB,WAAW;YAC5BlB;QACF;QAEAlB,IAAIqB,OAAO,CAACgB,MAAM,CAACC,IAAI,CAAC,CAAC,wBAAwB,EAAErB,OAAO,cAAc,EAAEV,eAAe,kBAAkB,CAAC;QAE5G,4EAA4E;QAC5E,8EAA8E;QAC9E,IAAIU,SAAS,GAAG;YACd,MAAMsB,aAAavC,IAAIqB,OAAO,CAACQ,IAAI,CAACW,GAAG,CAAC;gBAAEhB,OAAOP;
|
|
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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"}
|
|
@@ -48,7 +48,9 @@ export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
|
|
|
48
48
|
docId: String(doc.id)
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
|
-
const runPromise = req.payload.jobs.run(
|
|
51
|
+
const runPromise = req.payload.jobs.run({
|
|
52
|
+
sequential: true
|
|
53
|
+
}).catch((err)=>{
|
|
52
54
|
req.payload.logger.error({
|
|
53
55
|
err
|
|
54
56
|
}, 'Image optimizer job runner failed');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { isCloudStorage } from '../utilities/storage.js'\nimport { waitUntil } from '../utilities/waitUntil.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n // Use context flag from beforeChange instead of checking req.file.data directly.\n // Cloud storage adapters may consume req.file.data in their own afterChange hook\n // before ours runs, which would cause this guard to bail out and leave status as 'pending'.\n if (!context?.imageOptimizer_hasUpload) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // When using local storage, overwrite the file on disk with the processed buffer.\n // Payload's uploadFiles step writes the original buffer; we replace it here.\n // When using cloud storage, skip — the cloud adapter's afterChange hook already\n // uploads the correct buffer from req.file.data (set in our beforeChange hook).\n if (!cloudStorage) {\n const staticDir = resolveStaticDir(collectionConfig)\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n }\n\n // When status was already resolved in beforeChange (cloud storage, or\n // replaceOriginal with a single format), no async job or update is needed.\n // This avoids a separate update() call that fails with 404 on MongoDB due to\n // transaction isolation when cloud storage adapters are involved.\n if (context?.imageOptimizer_statusResolved) {\n return doc\n }\n\n // Queue async format conversion job for remaining variants (local storage only)\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n const runPromise = req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n waitUntil(runPromise, req)\n\n return doc\n }\n}\n"],"names":["fs","path","resolveStaticDir","isCloudStorage","waitUntil","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","imageOptimizer_hasUpload","collectionConfig","payload","collections","config","cloudStorage","staticDir","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","imageOptimizer_statusResolved","jobs","queue","task","input","docId","String","id","runPromise","run","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,cAAc,QAAQ,0BAAyB;AACxD,SAASC,SAAS,QAAQ,4BAA2B;AAErD,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,iFAAiF;QACjF,iFAAiF;QACjF,4FAA4F;QAC5F,IAAI,CAACD,SAASI,0BAA0B,OAAOH;QAE/C,MAAMI,mBAAmBH,IAAII,OAAO,CAACC,WAAW,CAACR,eAAuD,CAACS,MAAM;QAC/G,MAAMC,eAAed,eAAeU;QAEpC,kFAAkF;QAClF,6EAA6E;QAC7E,gFAAgF;QAChF,gFAAgF;QAChF,IAAI,CAACI,cAAc;YACjB,MAAMC,YAAYhB,iBAAiBW;YACnC,MAAMM,kBAAkBX,QAAQY,8BAA8B;YAC9D,IAAID,mBAAmBV,IAAIY,QAAQ,IAAIH,WAAW;gBAChD,MAAMI,eAAerB,KAAKsB,QAAQ,CAACd,IAAIY,QAAQ;gBAC/C,MAAMG,WAAWvB,KAAKwB,IAAI,CAACP,WAAWI;gBACtC,MAAMtB,GAAG0B,SAAS,CAACF,UAAUL;gBAE7B,+EAA+E;gBAC/E,MAAMQ,mBAAmBnB,QAAQoB,+BAA+B;gBAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;oBACzD,MAAMO,cAAc5B,KAAKwB,IAAI,CAACP,WAAWjB,KAAKsB,QAAQ,CAACI;oBACvD,MAAM3B,GAAG8B,MAAM,CAACD,aAAaE,KAAK,CAAC;oBACjC,0DAA0D;oBAC5D;gBACF;YACF;QACF;QAEA,sEAAsE;QACtE,2EAA2E;QAC3E,6EAA6E;QAC7E,kEAAkE;QAClE,IAAIvB,SAASwB,+BAA+B;YAC1C,OAAOvB;QACT;QAEA,gFAAgF;QAChF,MAAMC,IAAII,OAAO,CAACmB,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACL7B;gBACA8B,OAAOC,OAAO7B,IAAI8B,EAAE;YACtB;QACF;QAEA,MAAMC,aAAa9B,IAAII,OAAO,CAACmB,IAAI,CAACQ,GAAG,
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { isCloudStorage } from '../utilities/storage.js'\nimport { waitUntil } from '../utilities/waitUntil.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n // Use context flag from beforeChange instead of checking req.file.data directly.\n // Cloud storage adapters may consume req.file.data in their own afterChange hook\n // before ours runs, which would cause this guard to bail out and leave status as 'pending'.\n if (!context?.imageOptimizer_hasUpload) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // When using local storage, overwrite the file on disk with the processed buffer.\n // Payload's uploadFiles step writes the original buffer; we replace it here.\n // When using cloud storage, skip — the cloud adapter's afterChange hook already\n // uploads the correct buffer from req.file.data (set in our beforeChange hook).\n if (!cloudStorage) {\n const staticDir = resolveStaticDir(collectionConfig)\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n }\n\n // When status was already resolved in beforeChange (cloud storage, or\n // replaceOriginal with a single format), no async job or update is needed.\n // This avoids a separate update() call that fails with 404 on MongoDB due to\n // transaction isolation when cloud storage adapters are involved.\n if (context?.imageOptimizer_statusResolved) {\n return doc\n }\n\n // Queue async format conversion job for remaining variants (local storage only)\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n const runPromise = req.payload.jobs.run({ sequential: true }).catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n waitUntil(runPromise, req)\n\n return doc\n }\n}\n"],"names":["fs","path","resolveStaticDir","isCloudStorage","waitUntil","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","imageOptimizer_hasUpload","collectionConfig","payload","collections","config","cloudStorage","staticDir","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","imageOptimizer_statusResolved","jobs","queue","task","input","docId","String","id","runPromise","run","sequential","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,cAAc,QAAQ,0BAAyB;AACxD,SAASC,SAAS,QAAQ,4BAA2B;AAErD,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,iFAAiF;QACjF,iFAAiF;QACjF,4FAA4F;QAC5F,IAAI,CAACD,SAASI,0BAA0B,OAAOH;QAE/C,MAAMI,mBAAmBH,IAAII,OAAO,CAACC,WAAW,CAACR,eAAuD,CAACS,MAAM;QAC/G,MAAMC,eAAed,eAAeU;QAEpC,kFAAkF;QAClF,6EAA6E;QAC7E,gFAAgF;QAChF,gFAAgF;QAChF,IAAI,CAACI,cAAc;YACjB,MAAMC,YAAYhB,iBAAiBW;YACnC,MAAMM,kBAAkBX,QAAQY,8BAA8B;YAC9D,IAAID,mBAAmBV,IAAIY,QAAQ,IAAIH,WAAW;gBAChD,MAAMI,eAAerB,KAAKsB,QAAQ,CAACd,IAAIY,QAAQ;gBAC/C,MAAMG,WAAWvB,KAAKwB,IAAI,CAACP,WAAWI;gBACtC,MAAMtB,GAAG0B,SAAS,CAACF,UAAUL;gBAE7B,+EAA+E;gBAC/E,MAAMQ,mBAAmBnB,QAAQoB,+BAA+B;gBAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;oBACzD,MAAMO,cAAc5B,KAAKwB,IAAI,CAACP,WAAWjB,KAAKsB,QAAQ,CAACI;oBACvD,MAAM3B,GAAG8B,MAAM,CAACD,aAAaE,KAAK,CAAC;oBACjC,0DAA0D;oBAC5D;gBACF;YACF;QACF;QAEA,sEAAsE;QACtE,2EAA2E;QAC3E,6EAA6E;QAC7E,kEAAkE;QAClE,IAAIvB,SAASwB,+BAA+B;YAC1C,OAAOvB;QACT;QAEA,gFAAgF;QAChF,MAAMC,IAAII,OAAO,CAACmB,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACL7B;gBACA8B,OAAOC,OAAO7B,IAAI8B,EAAE;YACtB;QACF;QAEA,MAAMC,aAAa9B,IAAII,OAAO,CAACmB,IAAI,CAACQ,GAAG,CAAC;YAAEC,YAAY;QAAK,GAAGX,KAAK,CAAC,CAACY;YACnEjC,IAAII,OAAO,CAAC8B,MAAM,CAACC,KAAK,CAAC;gBAAEF;YAAI,GAAG;QACpC;QACAvC,UAAUoC,YAAY9B;QAEtB,OAAOD;IACT;AACF,EAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inoo-ch/payload-image-optimizer",
|
|
3
|
-
"version": "1.4.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
311
|
-
|
|
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
|
|
380
|
+
<span style={{ color: progress.errored > 0 ? '#f59e0b' : '#10b981' }}>
|
|
369
381
|
Done! {progress.complete}/{progress.total} optimized (across entire collection).
|
|
370
382
|
</span>
|
|
371
|
-
{
|
|
383
|
+
{progress.errored > 0 && (
|
|
372
384
|
<span style={{ color: '#ef4444' }}>
|
|
373
|
-
{' '}{progress.errored
|
|
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',
|
|
@@ -75,7 +75,7 @@ export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerCo
|
|
|
75
75
|
// Fire the job runner — use waitUntil to keep the serverless function alive
|
|
76
76
|
// after the response is sent, so jobs actually complete on Vercel/serverless.
|
|
77
77
|
if (queued > 0) {
|
|
78
|
-
const runPromise = req.payload.jobs.run({ limit: queued }).catch((err: unknown) => {
|
|
78
|
+
const runPromise = req.payload.jobs.run({ limit: queued, sequential: true }).catch((err: unknown) => {
|
|
79
79
|
req.payload.logger.error({ err }, 'Regeneration job runner failed')
|
|
80
80
|
})
|
|
81
81
|
waitUntil(runPromise, req)
|
package/src/hooks/afterChange.ts
CHANGED
|
@@ -62,7 +62,7 @@ export const createAfterChangeHook = (
|
|
|
62
62
|
},
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
const runPromise = req.payload.jobs.run().catch((err: unknown) => {
|
|
65
|
+
const runPromise = req.payload.jobs.run({ sequential: true }).catch((err: unknown) => {
|
|
66
66
|
req.payload.logger.error({ err }, 'Image optimizer job runner failed')
|
|
67
67
|
})
|
|
68
68
|
waitUntil(runPromise, req)
|