@inoo-ch/payload-image-optimizer 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_DOCS.md +383 -0
- package/package.json +34 -60
- package/src/components/ImageBox.tsx +80 -0
- package/src/components/OptimizationStatus.tsx +137 -0
- package/src/components/RegenerationButton.tsx +356 -0
- package/src/defaults.ts +36 -0
- package/src/endpoints/regenerate.ts +125 -0
- package/src/exports/client.ts +6 -0
- package/src/exports/rsc.ts +1 -0
- package/src/fields/imageOptimizerField.ts +70 -0
- package/src/hooks/afterChange.ts +77 -0
- package/src/hooks/beforeChange.ts +64 -0
- package/src/index.ts +125 -0
- package/src/next-image.d.ts +3 -0
- package/src/processing/index.ts +59 -0
- package/src/tasks/convertFormats.ts +107 -0
- package/src/tasks/regenerateDocument.ts +177 -0
- package/src/types.ts +38 -0
- package/src/utilities/getImageOptimizerProps.ts +58 -0
- package/src/utilities/thumbhash.ts +15 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { thumbHashToDataURL } from 'thumbhash'
|
|
5
|
+
import { useAllFormFields } from '@payloadcms/ui'
|
|
6
|
+
|
|
7
|
+
const formatBytes = (bytes: number): string => {
|
|
8
|
+
if (bytes === 0) return '0 B'
|
|
9
|
+
const k = 1024
|
|
10
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
11
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
12
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const statusColors: Record<string, string> = {
|
|
16
|
+
pending: '#f59e0b',
|
|
17
|
+
processing: '#3b82f6',
|
|
18
|
+
complete: '#10b981',
|
|
19
|
+
error: '#ef4444',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const OptimizationStatus: React.FC<{ path?: string }> = (props) => {
|
|
23
|
+
const [formState] = useAllFormFields()
|
|
24
|
+
const basePath = props.path ?? 'imageOptimizer'
|
|
25
|
+
|
|
26
|
+
const status = formState[`${basePath}.status`]?.value as string | undefined
|
|
27
|
+
const originalSize = formState[`${basePath}.originalSize`]?.value as number | undefined
|
|
28
|
+
const optimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined
|
|
29
|
+
const thumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined
|
|
30
|
+
const error = formState[`${basePath}.error`]?.value as string | undefined
|
|
31
|
+
|
|
32
|
+
const thumbHashUrl = React.useMemo(() => {
|
|
33
|
+
if (!thumbHash) return null
|
|
34
|
+
try {
|
|
35
|
+
const bytes = Uint8Array.from(atob(thumbHash), c => c.charCodeAt(0))
|
|
36
|
+
return thumbHashToDataURL(bytes)
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}, [thumbHash])
|
|
41
|
+
|
|
42
|
+
// Read variants array from form state
|
|
43
|
+
const variantsField = formState[`${basePath}.variants`]
|
|
44
|
+
const rowCount = (variantsField as any)?.rows?.length ?? 0
|
|
45
|
+
const variants: Array<{
|
|
46
|
+
format?: string
|
|
47
|
+
filename?: string
|
|
48
|
+
filesize?: number
|
|
49
|
+
width?: number
|
|
50
|
+
height?: number
|
|
51
|
+
}> = []
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < rowCount; i++) {
|
|
54
|
+
variants.push({
|
|
55
|
+
format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,
|
|
56
|
+
filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,
|
|
57
|
+
filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,
|
|
58
|
+
width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,
|
|
59
|
+
height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!status) {
|
|
64
|
+
return (
|
|
65
|
+
<div style={{ padding: '12px 0' }}>
|
|
66
|
+
<div style={{ color: '#6b7280', fontSize: '13px' }}>
|
|
67
|
+
No optimization data yet. Upload an image to optimize.
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const savings =
|
|
74
|
+
originalSize && optimizedSize
|
|
75
|
+
? Math.round((1 - optimizedSize / originalSize) * 100)
|
|
76
|
+
: null
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div style={{ padding: '12px 0' }}>
|
|
80
|
+
<div style={{ marginBottom: '8px' }}>
|
|
81
|
+
<span
|
|
82
|
+
style={{
|
|
83
|
+
backgroundColor: statusColors[status] || '#6b7280',
|
|
84
|
+
borderRadius: '4px',
|
|
85
|
+
color: '#fff',
|
|
86
|
+
display: 'inline-block',
|
|
87
|
+
fontSize: '12px',
|
|
88
|
+
fontWeight: 600,
|
|
89
|
+
padding: '2px 8px',
|
|
90
|
+
textTransform: 'uppercase',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{status}
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{error && (
|
|
98
|
+
<div style={{ color: '#ef4444', fontSize: '13px', marginBottom: '8px' }}>{error}</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{originalSize != null && optimizedSize != null && (
|
|
102
|
+
<div style={{ fontSize: '13px', marginBottom: '8px' }}>
|
|
103
|
+
<div>Original: <strong>{formatBytes(originalSize)}</strong></div>
|
|
104
|
+
<div>
|
|
105
|
+
Optimized: <strong>{formatBytes(optimizedSize)}</strong>
|
|
106
|
+
{savings != null && savings > 0 && (
|
|
107
|
+
<span style={{ color: '#10b981', marginLeft: '4px' }}>(-{savings}%)</span>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{thumbHashUrl && (
|
|
114
|
+
<div style={{ marginBottom: '8px' }}>
|
|
115
|
+
<div style={{ fontSize: '12px', marginBottom: '4px', opacity: 0.7 }}>Blur Preview</div>
|
|
116
|
+
<img
|
|
117
|
+
alt="Blur placeholder"
|
|
118
|
+
src={thumbHashUrl}
|
|
119
|
+
style={{ borderRadius: '4px', height: '40px', width: 'auto' }}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{variants.length > 0 && (
|
|
125
|
+
<div>
|
|
126
|
+
<div style={{ fontSize: '12px', marginBottom: '4px', opacity: 0.7 }}>Variants</div>
|
|
127
|
+
{variants.map((v, i) => (
|
|
128
|
+
<div key={i} style={{ fontSize: '12px', marginBottom: '2px' }}>
|
|
129
|
+
<strong>{v.format?.toUpperCase()}</strong> — {v.filesize ? formatBytes(v.filesize) : '?'}{' '}
|
|
130
|
+
({v.width}x{v.height})
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
type RegenerationProgress = {
|
|
6
|
+
total: number
|
|
7
|
+
complete: number
|
|
8
|
+
errored: number
|
|
9
|
+
pending: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STALL_THRESHOLD = 5
|
|
13
|
+
|
|
14
|
+
export const RegenerationButton: React.FC = () => {
|
|
15
|
+
const [isRunning, setIsRunning] = useState(false)
|
|
16
|
+
const [progress, setProgress] = useState<RegenerationProgress | null>(null)
|
|
17
|
+
const [queued, setQueued] = useState<number | null>(null)
|
|
18
|
+
const [force, setForce] = useState(false)
|
|
19
|
+
const [error, setError] = useState<string | null>(null)
|
|
20
|
+
const [stalled, setStalled] = useState(false)
|
|
21
|
+
const [collectionSlug, setCollectionSlug] = useState<string | null>(null)
|
|
22
|
+
const [stats, setStats] = useState<RegenerationProgress | null>(null)
|
|
23
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
24
|
+
const stallRef = useRef({ lastProcessed: 0, stallCount: 0 })
|
|
25
|
+
const prevIsRunningRef = useRef(false)
|
|
26
|
+
|
|
27
|
+
// Extract collection slug from URL after mount to avoid hydration mismatch
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const slug = window.location.pathname.split('/collections/')[1]?.split('/')[0] ?? null
|
|
30
|
+
setCollectionSlug(slug)
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
// Fetch optimization stats (independent of regeneration)
|
|
34
|
+
const fetchStats = useCallback(async () => {
|
|
35
|
+
if (!collectionSlug) return
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(
|
|
38
|
+
`/api/image-optimizer/regenerate?collection=${collectionSlug}`,
|
|
39
|
+
)
|
|
40
|
+
if (res.ok) {
|
|
41
|
+
const data: RegenerationProgress = await res.json()
|
|
42
|
+
setStats(data)
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore stats fetch errors
|
|
46
|
+
}
|
|
47
|
+
}, [collectionSlug])
|
|
48
|
+
|
|
49
|
+
const stopPolling = useCallback(() => {
|
|
50
|
+
if (intervalRef.current) {
|
|
51
|
+
clearInterval(intervalRef.current)
|
|
52
|
+
intervalRef.current = null
|
|
53
|
+
}
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
const pollProgress = useCallback(async () => {
|
|
57
|
+
if (!collectionSlug) return
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(
|
|
60
|
+
`/api/image-optimizer/regenerate?collection=${collectionSlug}`,
|
|
61
|
+
)
|
|
62
|
+
if (res.ok) {
|
|
63
|
+
const data: RegenerationProgress = await res.json()
|
|
64
|
+
setProgress(data)
|
|
65
|
+
|
|
66
|
+
// Stop polling when no more pending
|
|
67
|
+
if (data.pending <= 0) {
|
|
68
|
+
setIsRunning(false)
|
|
69
|
+
stopPolling()
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Stall detection
|
|
74
|
+
const processed = data.complete + data.errored
|
|
75
|
+
if (processed === stallRef.current.lastProcessed) {
|
|
76
|
+
stallRef.current.stallCount += 1
|
|
77
|
+
} else {
|
|
78
|
+
stallRef.current.stallCount = 0
|
|
79
|
+
stallRef.current.lastProcessed = processed
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (stallRef.current.stallCount >= STALL_THRESHOLD) {
|
|
83
|
+
stopPolling()
|
|
84
|
+
setIsRunning(false)
|
|
85
|
+
setStalled(true)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore polling errors
|
|
90
|
+
}
|
|
91
|
+
}, [collectionSlug, stopPolling])
|
|
92
|
+
|
|
93
|
+
// On mount (once collectionSlug is known), check if there's an ongoing job and resume polling
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!collectionSlug) return
|
|
96
|
+
let cancelled = false
|
|
97
|
+
const checkOngoing = async () => {
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(
|
|
100
|
+
`/api/image-optimizer/regenerate?collection=${collectionSlug}`,
|
|
101
|
+
)
|
|
102
|
+
if (!res.ok || cancelled) return
|
|
103
|
+
const data: RegenerationProgress = await res.json()
|
|
104
|
+
// Always store stats on mount
|
|
105
|
+
setStats(data)
|
|
106
|
+
if (data.pending > 0) {
|
|
107
|
+
setProgress(data)
|
|
108
|
+
setIsRunning(true)
|
|
109
|
+
setStalled(false)
|
|
110
|
+
setQueued(null)
|
|
111
|
+
stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }
|
|
112
|
+
intervalRef.current = setInterval(pollProgress, 2000)
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
checkOngoing()
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true
|
|
121
|
+
}
|
|
122
|
+
}, [collectionSlug, pollProgress])
|
|
123
|
+
|
|
124
|
+
// Refresh stats when regeneration finishes (isRunning transitions from true to false)
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (prevIsRunningRef.current && !isRunning) {
|
|
127
|
+
fetchStats()
|
|
128
|
+
}
|
|
129
|
+
prevIsRunningRef.current = isRunning
|
|
130
|
+
}, [isRunning, fetchStats])
|
|
131
|
+
|
|
132
|
+
const handleRegenerate = async () => {
|
|
133
|
+
if (!collectionSlug) return
|
|
134
|
+
setError(null)
|
|
135
|
+
setStalled(false)
|
|
136
|
+
setIsRunning(true)
|
|
137
|
+
setQueued(null)
|
|
138
|
+
setProgress(null)
|
|
139
|
+
stallRef.current = { lastProcessed: 0, stallCount: 0 }
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch('/api/image-optimizer/regenerate', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({ collectionSlug, force }),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
const data = await res.json()
|
|
150
|
+
throw new Error(data.error || 'Failed to start regeneration')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = await res.json()
|
|
154
|
+
setQueued(data.queued)
|
|
155
|
+
|
|
156
|
+
if (data.queued === 0) {
|
|
157
|
+
setIsRunning(false)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Start polling
|
|
162
|
+
intervalRef.current = setInterval(pollProgress, 2000)
|
|
163
|
+
} catch (err) {
|
|
164
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
165
|
+
setIsRunning(false)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Cleanup interval on unmount
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
return () => {
|
|
172
|
+
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
173
|
+
}
|
|
174
|
+
}, [])
|
|
175
|
+
|
|
176
|
+
if (!collectionSlug) return null
|
|
177
|
+
|
|
178
|
+
const progressPercent =
|
|
179
|
+
progress && progress.total > 0
|
|
180
|
+
? Math.round((progress.complete / progress.total) * 100)
|
|
181
|
+
: 0
|
|
182
|
+
|
|
183
|
+
const showProgressBar = (isRunning && progress) || (stalled && progress)
|
|
184
|
+
|
|
185
|
+
// Stats computations
|
|
186
|
+
const statsPercent =
|
|
187
|
+
stats && stats.total > 0
|
|
188
|
+
? Math.round((stats.complete / stats.total) * 100)
|
|
189
|
+
: 0
|
|
190
|
+
const allOptimized = stats && stats.total > 0 && stats.complete === stats.total
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div
|
|
194
|
+
style={{
|
|
195
|
+
padding: '16px 24px',
|
|
196
|
+
borderBottom: '1px solid #e5e7eb',
|
|
197
|
+
display: 'flex',
|
|
198
|
+
alignItems: 'center',
|
|
199
|
+
gap: '16px',
|
|
200
|
+
flexWrap: 'wrap',
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<button
|
|
204
|
+
onClick={handleRegenerate}
|
|
205
|
+
disabled={isRunning}
|
|
206
|
+
style={{
|
|
207
|
+
backgroundColor: isRunning ? '#9ca3af' : '#4f46e5',
|
|
208
|
+
color: '#fff',
|
|
209
|
+
border: 'none',
|
|
210
|
+
borderRadius: '6px',
|
|
211
|
+
padding: '8px 16px',
|
|
212
|
+
fontSize: '14px',
|
|
213
|
+
fontWeight: 500,
|
|
214
|
+
cursor: isRunning ? 'not-allowed' : 'pointer',
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
{isRunning ? 'Regenerating...' : 'Regenerate Images'}
|
|
218
|
+
</button>
|
|
219
|
+
|
|
220
|
+
<label
|
|
221
|
+
style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px' }}
|
|
222
|
+
>
|
|
223
|
+
<input
|
|
224
|
+
type="checkbox"
|
|
225
|
+
checked={force}
|
|
226
|
+
onChange={(e) => setForce(e.target.checked)}
|
|
227
|
+
disabled={isRunning}
|
|
228
|
+
/>
|
|
229
|
+
Force re-process all
|
|
230
|
+
</label>
|
|
231
|
+
|
|
232
|
+
{error && (
|
|
233
|
+
<span style={{ color: '#ef4444', fontSize: '13px' }}>{error}</span>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{queued === 0 && !isRunning && !stalled && (
|
|
237
|
+
<span style={{ color: '#10b981', fontSize: '13px' }}>
|
|
238
|
+
All images already optimized.
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{stalled && progress && (
|
|
243
|
+
<span style={{ color: '#f59e0b', fontSize: '13px' }}>
|
|
244
|
+
Process stalled. {progress.pending} image{progress.pending !== 1 ? 's' : ''} failed to process.
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
{showProgressBar && (
|
|
249
|
+
<div style={{ flex: 1, minWidth: '200px' }}>
|
|
250
|
+
<div
|
|
251
|
+
style={{
|
|
252
|
+
display: 'flex',
|
|
253
|
+
justifyContent: 'space-between',
|
|
254
|
+
fontSize: '12px',
|
|
255
|
+
marginBottom: '4px',
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<span>
|
|
259
|
+
{progress.complete} / {progress.total} complete
|
|
260
|
+
</span>
|
|
261
|
+
{progress.errored > 0 && (
|
|
262
|
+
<span style={{ color: '#ef4444' }}>{progress.errored} errors</span>
|
|
263
|
+
)}
|
|
264
|
+
<span>{progressPercent}%</span>
|
|
265
|
+
</div>
|
|
266
|
+
<div
|
|
267
|
+
style={{
|
|
268
|
+
height: '6px',
|
|
269
|
+
backgroundColor: '#e5e7eb',
|
|
270
|
+
borderRadius: '3px',
|
|
271
|
+
overflow: 'hidden',
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
<div
|
|
275
|
+
style={{
|
|
276
|
+
height: '100%',
|
|
277
|
+
width: `${progressPercent}%`,
|
|
278
|
+
backgroundColor: stalled ? '#f59e0b' : '#10b981',
|
|
279
|
+
borderRadius: '3px',
|
|
280
|
+
transition: 'width 0.3s ease',
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{!isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && (
|
|
288
|
+
<span style={{ fontSize: '13px' }}>
|
|
289
|
+
<span style={{ color: '#10b981' }}>
|
|
290
|
+
Done! {progress.complete}/{progress.total} optimized.
|
|
291
|
+
</span>
|
|
292
|
+
{progress.errored > 0 && (
|
|
293
|
+
<span style={{ color: '#ef4444' }}>
|
|
294
|
+
{' '}{progress.errored} failed.
|
|
295
|
+
</span>
|
|
296
|
+
)}
|
|
297
|
+
</span>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* Persistent optimization stats — always visible when not actively regenerating */}
|
|
301
|
+
{!isRunning && stats && stats.total > 0 && (
|
|
302
|
+
<div
|
|
303
|
+
style={{
|
|
304
|
+
marginLeft: 'auto',
|
|
305
|
+
display: 'flex',
|
|
306
|
+
flexDirection: 'column',
|
|
307
|
+
alignItems: 'flex-end',
|
|
308
|
+
gap: '4px',
|
|
309
|
+
minWidth: '180px',
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
|
|
313
|
+
{allOptimized ? (
|
|
314
|
+
<span style={{ color: '#10b981' }}>
|
|
315
|
+
✓ All {stats.total} images optimized
|
|
316
|
+
</span>
|
|
317
|
+
) : (
|
|
318
|
+
<>
|
|
319
|
+
<span style={{ color: '#6b7280' }}>
|
|
320
|
+
{stats.complete}/{stats.total} optimized
|
|
321
|
+
</span>
|
|
322
|
+
{stats.errored > 0 && (
|
|
323
|
+
<>
|
|
324
|
+
<span style={{ color: '#d1d5db' }}>·</span>
|
|
325
|
+
<span style={{ color: '#ef4444' }}>{stats.errored} errors</span>
|
|
326
|
+
</>
|
|
327
|
+
)}
|
|
328
|
+
</>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
{!allOptimized && (
|
|
332
|
+
<div
|
|
333
|
+
style={{
|
|
334
|
+
width: '100%',
|
|
335
|
+
height: '3px',
|
|
336
|
+
backgroundColor: '#e5e7eb',
|
|
337
|
+
borderRadius: '2px',
|
|
338
|
+
overflow: 'hidden',
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
<div
|
|
342
|
+
style={{
|
|
343
|
+
height: '100%',
|
|
344
|
+
width: `${statsPercent}%`,
|
|
345
|
+
backgroundColor: stats.errored > 0 ? '#f59e0b' : '#10b981',
|
|
346
|
+
borderRadius: '2px',
|
|
347
|
+
transition: 'width 0.3s ease',
|
|
348
|
+
}}
|
|
349
|
+
/>
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
)
|
|
356
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CollectionSlug } from 'payload'
|
|
2
|
+
|
|
3
|
+
import type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'
|
|
4
|
+
|
|
5
|
+
export const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({
|
|
6
|
+
collections: config.collections,
|
|
7
|
+
disabled: config.disabled ?? false,
|
|
8
|
+
formats: config.formats ?? [
|
|
9
|
+
{ format: 'webp', quality: 80 },
|
|
10
|
+
],
|
|
11
|
+
generateThumbHash: config.generateThumbHash ?? true,
|
|
12
|
+
maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },
|
|
13
|
+
replaceOriginal: config.replaceOriginal ?? true,
|
|
14
|
+
stripMetadata: config.stripMetadata ?? true,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const resolveCollectionConfig = (
|
|
18
|
+
resolvedConfig: ResolvedImageOptimizerConfig,
|
|
19
|
+
collectionSlug: string,
|
|
20
|
+
): ResolvedCollectionOptimizerConfig => {
|
|
21
|
+
const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]
|
|
22
|
+
|
|
23
|
+
if (!collectionValue || collectionValue === true) {
|
|
24
|
+
return {
|
|
25
|
+
formats: resolvedConfig.formats,
|
|
26
|
+
maxDimensions: resolvedConfig.maxDimensions,
|
|
27
|
+
replaceOriginal: resolvedConfig.replaceOriginal,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
formats: collectionValue.formats ?? resolvedConfig.formats,
|
|
33
|
+
maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,
|
|
34
|
+
replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PayloadHandler } from 'payload'
|
|
2
|
+
import type { CollectionSlug } from 'payload'
|
|
3
|
+
|
|
4
|
+
import type { ResolvedImageOptimizerConfig } from '../types.js'
|
|
5
|
+
|
|
6
|
+
export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
|
|
7
|
+
const handler: PayloadHandler = async (req) => {
|
|
8
|
+
if (!req.user) {
|
|
9
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let body: { collectionSlug?: string; force?: boolean }
|
|
13
|
+
try {
|
|
14
|
+
body = await req.json!()
|
|
15
|
+
} catch {
|
|
16
|
+
body = {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const collectionSlug = body.collectionSlug
|
|
20
|
+
if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {
|
|
21
|
+
return Response.json(
|
|
22
|
+
{ error: 'Invalid or unconfigured collection slug' },
|
|
23
|
+
{ status: 400 },
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Find all image documents in the collection
|
|
28
|
+
const where: any = {
|
|
29
|
+
mimeType: { contains: 'image/' },
|
|
30
|
+
}
|
|
31
|
+
// Unless force=true, skip already-processed docs
|
|
32
|
+
if (!body.force) {
|
|
33
|
+
where.or = [
|
|
34
|
+
{ 'imageOptimizer.status': { not_equals: 'complete' } },
|
|
35
|
+
{ 'imageOptimizer.status': { exists: false } },
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let queued = 0
|
|
40
|
+
let page = 1
|
|
41
|
+
let hasMore = true
|
|
42
|
+
|
|
43
|
+
while (hasMore) {
|
|
44
|
+
const result = await req.payload.find({
|
|
45
|
+
collection: collectionSlug as CollectionSlug,
|
|
46
|
+
limit: 50,
|
|
47
|
+
page,
|
|
48
|
+
depth: 0,
|
|
49
|
+
where,
|
|
50
|
+
sort: 'createdAt',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
for (const doc of result.docs) {
|
|
54
|
+
await req.payload.jobs.queue({
|
|
55
|
+
task: 'imageOptimizer_regenerateDocument',
|
|
56
|
+
input: {
|
|
57
|
+
collectionSlug,
|
|
58
|
+
docId: String(doc.id),
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
queued++
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
hasMore = result.hasNextPage
|
|
65
|
+
page++
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fire the job runner (non-blocking)
|
|
69
|
+
if (queued > 0) {
|
|
70
|
+
req.payload.jobs.run().catch((err: unknown) => {
|
|
71
|
+
req.payload.logger.error({ err }, 'Regeneration job runner failed')
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Response.json({ queued, collectionSlug })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return handler
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const createRegenerateStatusHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
|
|
82
|
+
const handler: PayloadHandler = async (req) => {
|
|
83
|
+
if (!req.user) {
|
|
84
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const url = new URL(req.url!)
|
|
88
|
+
const collectionSlug = url.searchParams.get('collection')
|
|
89
|
+
|
|
90
|
+
if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {
|
|
91
|
+
return Response.json({ error: 'Invalid collection slug' }, { status: 400 })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const total = await req.payload.count({
|
|
95
|
+
collection: collectionSlug as CollectionSlug,
|
|
96
|
+
where: { mimeType: { contains: 'image/' } },
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const complete = await req.payload.count({
|
|
100
|
+
collection: collectionSlug as CollectionSlug,
|
|
101
|
+
where: {
|
|
102
|
+
mimeType: { contains: 'image/' },
|
|
103
|
+
'imageOptimizer.status': { equals: 'complete' },
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const errored = await req.payload.count({
|
|
108
|
+
collection: collectionSlug as CollectionSlug,
|
|
109
|
+
where: {
|
|
110
|
+
mimeType: { contains: 'image/' },
|
|
111
|
+
'imageOptimizer.status': { equals: 'error' },
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return Response.json({
|
|
116
|
+
collectionSlug,
|
|
117
|
+
total: total.totalDocs,
|
|
118
|
+
complete: complete.totalDocs,
|
|
119
|
+
errored: errored.totalDocs,
|
|
120
|
+
pending: total.totalDocs - complete.totalDocs - errored.totalDocs,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return handler
|
|
125
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { OptimizationStatus } from '../components/OptimizationStatus.js'
|
|
2
|
+
export { ImageBox } from '../components/ImageBox.js'
|
|
3
|
+
export type { ImageBoxProps } from '../components/ImageBox.js'
|
|
4
|
+
export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'
|
|
5
|
+
export type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'
|
|
6
|
+
export { RegenerationButton } from '../components/RegenerationButton.js'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// No RSC components exported by this plugin
|