@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.
@@ -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
+ &#10003; 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' }}>&middot;</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
+ }
@@ -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