@inoo-ch/payload-image-optimizer 1.1.0 → 1.2.0
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/dist/components/ImageBox.d.ts +1 -15
- package/dist/components/ImageBox.js.map +1 -1
- package/dist/components/OptimizationStatus.js +84 -19
- package/dist/components/OptimizationStatus.js.map +1 -1
- package/dist/fields/imageOptimizerField.d.ts +4 -2
- package/dist/fields/imageOptimizerField.js +57 -54
- package/dist/fields/imageOptimizerField.js.map +1 -1
- package/dist/hooks/afterChange.js +2 -4
- package/dist/hooks/afterChange.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +123 -105
- package/dist/index.js.map +1 -1
- package/dist/tasks/convertFormats.js +2 -4
- package/dist/tasks/convertFormats.js.map +1 -1
- package/dist/tasks/regenerateDocument.js +2 -4
- package/dist/tasks/regenerateDocument.js.map +1 -1
- package/dist/translations/index.d.ts +1 -0
- package/dist/translations/index.js +64 -0
- package/dist/translations/index.js.map +1 -0
- package/dist/types.d.ts +19 -1
- package/dist/types.js.map +1 -1
- package/dist/utilities/getImageOptimizerProps.d.ts +2 -10
- package/dist/utilities/getImageOptimizerProps.js.map +1 -1
- package/dist/utilities/resolveStaticDir.d.ts +3 -0
- package/dist/utilities/resolveStaticDir.js +10 -0
- package/dist/utilities/resolveStaticDir.js.map +1 -0
- package/package.json +36 -61
- package/src/components/ImageBox.tsx +65 -0
- package/src/components/OptimizationStatus.tsx +216 -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 +76 -0
- package/src/hooks/afterChange.ts +73 -0
- package/src/hooks/beforeChange.ts +64 -0
- package/src/index.ts +124 -0
- package/src/next-image.d.ts +3 -0
- package/src/processing/index.ts +59 -0
- package/src/tasks/convertFormats.ts +104 -0
- package/src/tasks/regenerateDocument.ts +174 -0
- package/src/translations/index.ts +62 -0
- package/src/types.ts +57 -0
- package/src/utilities/getImageOptimizerProps.ts +50 -0
- package/src/utilities/resolveStaticDir.ts +12 -0
- package/src/utilities/thumbhash.ts +15 -0
package/AGENT_DOCS.md
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# @inoo-ch/payload-image-optimizer
|
|
2
|
+
|
|
3
|
+
Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash blur placeholders, and bulk regeneration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @inoo-ch/payload-image-optimizer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Peer dependency:** Payload 3.x must provide `sharp` in its config.
|
|
12
|
+
|
|
13
|
+
## Quick Start (Zero-Config)
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// payload.config.ts
|
|
17
|
+
import { imageOptimizer } from '@inoo-ch/payload-image-optimizer'
|
|
18
|
+
|
|
19
|
+
export default buildConfig({
|
|
20
|
+
// ...
|
|
21
|
+
plugins: [
|
|
22
|
+
imageOptimizer({
|
|
23
|
+
collections: {
|
|
24
|
+
media: true, // enable with all defaults
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
sharp, // required — Payload must be configured with sharp
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With this minimal config every uploaded image in the `media` collection will automatically:
|
|
33
|
+
|
|
34
|
+
1. Be resized to fit within 2560x2560 (no upscaling)
|
|
35
|
+
2. Have EXIF/metadata stripped
|
|
36
|
+
3. Be converted to WebP (quality 80) — replacing the original file on disk
|
|
37
|
+
4. Get a ThumbHash blur placeholder generated
|
|
38
|
+
|
|
39
|
+
## Configuration Reference
|
|
40
|
+
|
|
41
|
+
### Plugin Options (`ImageOptimizerConfig`)
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
imageOptimizer({
|
|
45
|
+
// Required — map of collection slugs to optimize.
|
|
46
|
+
// Use `true` for defaults, or an object for per-collection overrides.
|
|
47
|
+
collections: {
|
|
48
|
+
media: true,
|
|
49
|
+
avatars: {
|
|
50
|
+
maxDimensions: { width: 256, height: 256 },
|
|
51
|
+
formats: [{ format: 'webp', quality: 90 }],
|
|
52
|
+
replaceOriginal: false,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Global defaults (all optional — values shown are the defaults):
|
|
57
|
+
formats: [{ format: 'webp', quality: 80 }], // output formats
|
|
58
|
+
maxDimensions: { width: 2560, height: 2560 }, // max resize dimensions
|
|
59
|
+
stripMetadata: true, // strip EXIF data
|
|
60
|
+
generateThumbHash: true, // generate blur placeholders
|
|
61
|
+
replaceOriginal: true, // convert main file to primary format
|
|
62
|
+
disabled: false, // keep fields but skip all processing
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Option | Type | Default | Description |
|
|
67
|
+
|--------|------|---------|-------------|
|
|
68
|
+
| `collections` | `Record<slug, true \| CollectionConfig>` | — | **Required.** Collections to optimize. `true` = use global defaults. |
|
|
69
|
+
| `formats` | `{ format: 'webp' \| 'avif', quality: number }[]` | `[{ format: 'webp', quality: 80 }]` | Output formats to generate. |
|
|
70
|
+
| `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions (fit inside, no upscaling). |
|
|
71
|
+
| `stripMetadata` | `boolean` | `true` | Strip EXIF, ICC, XMP metadata. |
|
|
72
|
+
| `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholder. |
|
|
73
|
+
| `replaceOriginal` | `boolean` | `true` | Replace the original file with the primary format. |
|
|
74
|
+
| `disabled` | `boolean` | `false` | Keep schema fields but disable all processing. |
|
|
75
|
+
|
|
76
|
+
### Per-Collection Overrides (`CollectionOptimizerConfig`)
|
|
77
|
+
|
|
78
|
+
Each collection can override `formats`, `maxDimensions`, and `replaceOriginal`. All other settings are global-only.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
collections: {
|
|
82
|
+
media: true, // uses global defaults
|
|
83
|
+
avatars: { // overrides specific settings
|
|
84
|
+
formats: [{ format: 'webp', quality: 90 }],
|
|
85
|
+
maxDimensions: { width: 256, height: 256 },
|
|
86
|
+
replaceOriginal: false,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## How It Works
|
|
92
|
+
|
|
93
|
+
### Upload Pipeline
|
|
94
|
+
|
|
95
|
+
When an image is uploaded to an optimized collection:
|
|
96
|
+
|
|
97
|
+
1. **`beforeChange` hook** (in-memory processing):
|
|
98
|
+
- Auto-rotates based on EXIF orientation
|
|
99
|
+
- Resizes to fit within `maxDimensions`
|
|
100
|
+
- Strips metadata (if enabled)
|
|
101
|
+
- If `replaceOriginal: true`: converts to primary format (first in `formats` array), updates filename/mimeType
|
|
102
|
+
- Generates ThumbHash (if enabled)
|
|
103
|
+
- Sets `imageOptimizer.status = 'pending'`
|
|
104
|
+
|
|
105
|
+
2. **`afterChange` hook** (disk + async):
|
|
106
|
+
- Writes processed buffer to disk (overwriting Payload's original)
|
|
107
|
+
- Cleans up old file if filename changed
|
|
108
|
+
- Queues `imageOptimizer_convertFormats` background job for remaining formats
|
|
109
|
+
|
|
110
|
+
3. **Background job** (`imageOptimizer_convertFormats`):
|
|
111
|
+
- Generates variant files for any additional formats (e.g., AVIF)
|
|
112
|
+
- Writes variants to disk with `-optimized` suffix
|
|
113
|
+
- Updates document: `imageOptimizer.status = 'complete'`, populates `variants` array
|
|
114
|
+
|
|
115
|
+
### File Naming
|
|
116
|
+
|
|
117
|
+
| File | Naming Pattern | Example |
|
|
118
|
+
|------|---------------|---------|
|
|
119
|
+
| Main file (replaceOriginal) | `{name}.{primaryFormat}` | `photo.webp` |
|
|
120
|
+
| Variant files | `{name}-optimized.{format}` | `photo-optimized.avif` |
|
|
121
|
+
|
|
122
|
+
### Format Behavior
|
|
123
|
+
|
|
124
|
+
**When `replaceOriginal: true`** (default):
|
|
125
|
+
- The uploaded file is converted to the first format in the `formats` array and replaces the original on disk.
|
|
126
|
+
- Additional formats are generated as variant files.
|
|
127
|
+
- Example: `formats: [webp, avif]` → main file becomes `.webp`, variant is `.avif`
|
|
128
|
+
|
|
129
|
+
**When `replaceOriginal: false`**:
|
|
130
|
+
- The uploaded file stays in its original format.
|
|
131
|
+
- All configured formats are generated as separate variant files.
|
|
132
|
+
|
|
133
|
+
## Fields Added to Collections
|
|
134
|
+
|
|
135
|
+
The plugin adds an `imageOptimizer` group field (read-only, displayed in the admin sidebar) to every configured collection:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
{
|
|
139
|
+
imageOptimizer: {
|
|
140
|
+
status: 'pending' | 'processing' | 'complete' | 'error',
|
|
141
|
+
error: string | null,
|
|
142
|
+
thumbHash: string | null, // base64-encoded ThumbHash
|
|
143
|
+
originalSize: number, // bytes
|
|
144
|
+
optimizedSize: number, // bytes
|
|
145
|
+
variants: [
|
|
146
|
+
{
|
|
147
|
+
format: string, // 'webp' | 'avif'
|
|
148
|
+
filename: string, // e.g. 'photo-optimized.avif'
|
|
149
|
+
filesize: number, // bytes
|
|
150
|
+
width: number,
|
|
151
|
+
height: number,
|
|
152
|
+
mimeType: string, // e.g. 'image/avif'
|
|
153
|
+
url: string, // e.g. '/media/photo-optimized.avif'
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Admin UI
|
|
161
|
+
|
|
162
|
+
### Optimization Status (Document Sidebar)
|
|
163
|
+
|
|
164
|
+
Every document in an optimized collection shows an `OptimizationStatus` component in the sidebar displaying:
|
|
165
|
+
- Color-coded status badge (pending/processing/complete/error)
|
|
166
|
+
- Original vs optimized file sizes with savings percentage
|
|
167
|
+
- ThumbHash preview thumbnail
|
|
168
|
+
- List of generated variants
|
|
169
|
+
|
|
170
|
+
### Regenerate Images (Collection List View)
|
|
171
|
+
|
|
172
|
+
A `RegenerationButton` component is injected above the list table in every optimized collection:
|
|
173
|
+
- **"Regenerate Images"** button — queues optimization jobs for all images
|
|
174
|
+
- **"Force re-process all"** checkbox — re-optimizes already-complete images
|
|
175
|
+
- Live progress bar with polling (every 2 seconds)
|
|
176
|
+
- Stall detection — warns if processing stops progressing
|
|
177
|
+
- Persistent stats showing overall optimization status (e.g., "All 32 images optimized")
|
|
178
|
+
|
|
179
|
+
## REST API Endpoints
|
|
180
|
+
|
|
181
|
+
### `POST /api/image-optimizer/regenerate`
|
|
182
|
+
|
|
183
|
+
Queue bulk regeneration jobs for a collection. Requires authentication.
|
|
184
|
+
|
|
185
|
+
**Request body:**
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"collectionSlug": "media",
|
|
189
|
+
"force": false
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Response:**
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"queued": 12,
|
|
197
|
+
"collectionSlug": "media"
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### `GET /api/image-optimizer/regenerate?collection=media`
|
|
202
|
+
|
|
203
|
+
Get current optimization status for a collection. Requires authentication.
|
|
204
|
+
|
|
205
|
+
**Response:**
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"collectionSlug": "media",
|
|
209
|
+
"total": 32,
|
|
210
|
+
"complete": 30,
|
|
211
|
+
"errored": 1,
|
|
212
|
+
"pending": 1
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Client-Side Utilities
|
|
217
|
+
|
|
218
|
+
Import from `@inoo-ch/payload-image-optimizer/client`:
|
|
219
|
+
|
|
220
|
+
### `ImageBox` Component
|
|
221
|
+
|
|
222
|
+
Drop-in Next.js `<Image>` wrapper with automatic ThumbHash blur placeholders and focal point support.
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
|
|
226
|
+
|
|
227
|
+
// With a Payload media resource object
|
|
228
|
+
<ImageBox media={doc.image} alt="Hero" fill sizes="100vw" />
|
|
229
|
+
|
|
230
|
+
// With a plain URL string
|
|
231
|
+
<ImageBox media="/images/fallback.jpg" alt="Fallback" width={800} height={600} />
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Props:** Extends all Next.js `ImageProps` (except `src`), plus:
|
|
235
|
+
|
|
236
|
+
| Prop | Type | Description |
|
|
237
|
+
|------|------|-------------|
|
|
238
|
+
| `media` | `MediaResource \| string` | Payload media document or URL string |
|
|
239
|
+
| `alt` | `string` | Alt text (overrides `media.alt`) |
|
|
240
|
+
|
|
241
|
+
Automatically applies:
|
|
242
|
+
- ThumbHash blur placeholder (if available on the media resource)
|
|
243
|
+
- Focal point positioning via `objectPosition` (using `focalX`/`focalY`)
|
|
244
|
+
- Cache-busting via `updatedAt` query parameter
|
|
245
|
+
- `objectFit: 'cover'` by default (overridable via `style`)
|
|
246
|
+
|
|
247
|
+
### `getImageOptimizerProps()` Utility
|
|
248
|
+
|
|
249
|
+
For integrating with existing image components (e.g., the Payload website template's `ImageMedia`):
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
import { getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
|
|
253
|
+
import NextImage from 'next/image'
|
|
254
|
+
|
|
255
|
+
const optimizerProps = getImageOptimizerProps(media)
|
|
256
|
+
|
|
257
|
+
<NextImage
|
|
258
|
+
src={media.url}
|
|
259
|
+
alt={media.alt}
|
|
260
|
+
{...optimizerProps}
|
|
261
|
+
/>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Returns:**
|
|
265
|
+
```ts
|
|
266
|
+
{
|
|
267
|
+
placeholder: 'blur' | 'empty',
|
|
268
|
+
blurDataURL?: string, // data URL from ThumbHash (only when placeholder is 'blur')
|
|
269
|
+
style: {
|
|
270
|
+
objectPosition: string, // e.g. '50% 30%' from focalX/focalY, or 'center'
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Server-Side Utilities
|
|
276
|
+
|
|
277
|
+
Import from `@inoo-ch/payload-image-optimizer`:
|
|
278
|
+
|
|
279
|
+
### `encodeImageToThumbHash(buffer, width, height)`
|
|
280
|
+
|
|
281
|
+
Encode raw RGBA pixel data to a base64 ThumbHash string.
|
|
282
|
+
|
|
283
|
+
### `decodeThumbHashToDataURL(thumbHash)`
|
|
284
|
+
|
|
285
|
+
Decode a base64 ThumbHash string to a data URL for use as an `<img src>`.
|
|
286
|
+
|
|
287
|
+
## Background Jobs
|
|
288
|
+
|
|
289
|
+
The plugin registers two Payload job tasks (retries: 2 each):
|
|
290
|
+
|
|
291
|
+
| Task Slug | Trigger | Purpose |
|
|
292
|
+
|-----------|---------|---------|
|
|
293
|
+
| `imageOptimizer_convertFormats` | After upload (`afterChange` hook) | Generate format variants for a single document |
|
|
294
|
+
| `imageOptimizer_regenerateDocument` | Bulk regeneration endpoint | Fully re-optimize a single document (resize + thumbhash + all variants) |
|
|
295
|
+
|
|
296
|
+
## Full Example
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
// payload.config.ts
|
|
300
|
+
import { buildConfig } from 'payload'
|
|
301
|
+
import { imageOptimizer } from '@inoo-ch/payload-image-optimizer'
|
|
302
|
+
import sharp from 'sharp'
|
|
303
|
+
|
|
304
|
+
export default buildConfig({
|
|
305
|
+
collections: [
|
|
306
|
+
{
|
|
307
|
+
slug: 'media',
|
|
308
|
+
fields: [],
|
|
309
|
+
upload: { staticDir: './media' },
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
slug: 'avatars',
|
|
313
|
+
fields: [],
|
|
314
|
+
upload: { staticDir: './avatars' },
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
plugins: [
|
|
318
|
+
imageOptimizer({
|
|
319
|
+
collections: {
|
|
320
|
+
media: true, // all defaults: webp@80, 2560x2560, strip, thumbhash
|
|
321
|
+
avatars: {
|
|
322
|
+
maxDimensions: { width: 256, height: 256 },
|
|
323
|
+
formats: [{ format: 'webp', quality: 90 }],
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
formats: [
|
|
327
|
+
{ format: 'webp', quality: 80 },
|
|
328
|
+
{ format: 'avif', quality: 65 },
|
|
329
|
+
],
|
|
330
|
+
}),
|
|
331
|
+
],
|
|
332
|
+
sharp,
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
// components/Hero.tsx
|
|
338
|
+
import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
|
|
339
|
+
|
|
340
|
+
export function Hero({ image }) {
|
|
341
|
+
return (
|
|
342
|
+
<div style={{ position: 'relative', height: '60vh' }}>
|
|
343
|
+
<ImageBox media={image} alt="Hero" fill sizes="100vw" priority />
|
|
344
|
+
</div>
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
## TypeScript
|
|
350
|
+
|
|
351
|
+
Exported types:
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
import type {
|
|
355
|
+
ImageOptimizerConfig,
|
|
356
|
+
CollectionOptimizerConfig,
|
|
357
|
+
FormatQuality,
|
|
358
|
+
ImageFormat, // 'webp' | 'avif'
|
|
359
|
+
} from '@inoo-ch/payload-image-optimizer'
|
|
360
|
+
|
|
361
|
+
import type {
|
|
362
|
+
ImageBoxProps,
|
|
363
|
+
ImageOptimizerProps, // return type of getImageOptimizerProps
|
|
364
|
+
} from '@inoo-ch/payload-image-optimizer/client'
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Context Flags
|
|
368
|
+
|
|
369
|
+
The plugin uses `req.context` flags to control processing:
|
|
370
|
+
|
|
371
|
+
| Flag | Purpose |
|
|
372
|
+
|------|---------|
|
|
373
|
+
| `imageOptimizer_skip: true` | Set this on `req.context` to skip all optimization for a specific operation (useful for programmatic updates that shouldn't re-trigger processing). |
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
// Example: update a media doc without re-processing
|
|
377
|
+
await payload.update({
|
|
378
|
+
collection: 'media',
|
|
379
|
+
id: docId,
|
|
380
|
+
data: { alt: 'Updated alt text' },
|
|
381
|
+
context: { imageOptimizer_skip: true },
|
|
382
|
+
})
|
|
383
|
+
```
|
|
@@ -1,22 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type ImageProps } from 'next/image';
|
|
3
|
-
type
|
|
4
|
-
thumbHash?: string | null;
|
|
5
|
-
};
|
|
6
|
-
type MediaResource = {
|
|
7
|
-
url?: string | null;
|
|
8
|
-
alt?: string | null;
|
|
9
|
-
width?: number | null;
|
|
10
|
-
height?: number | null;
|
|
11
|
-
filename?: string | null;
|
|
12
|
-
focalX?: number | null;
|
|
13
|
-
focalY?: number | null;
|
|
14
|
-
imageOptimizer?: ImageOptimizerData | null;
|
|
15
|
-
updatedAt?: string;
|
|
16
|
-
};
|
|
3
|
+
import type { MediaResource } from '../types.js';
|
|
17
4
|
export interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {
|
|
18
5
|
media: MediaResource | string;
|
|
19
6
|
alt?: string;
|
|
20
7
|
}
|
|
21
8
|
export declare const ImageBox: React.FC<ImageBoxProps>;
|
|
22
|
-
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/ImageBox.tsx"],"sourcesContent":["'use client'\n\nimport React from 'react'\nimport NextImage, { type ImageProps } from 'next/image'\nimport {
|
|
1
|
+
{"version":3,"sources":["../../src/components/ImageBox.tsx"],"sourcesContent":["'use client'\n\nimport React from 'react'\nimport NextImage, { type ImageProps } from 'next/image'\nimport type { MediaResource } from '../types.js'\nimport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\n\nexport interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {\n media: MediaResource | string\n alt?: string\n}\n\nexport const ImageBox: React.FC<ImageBoxProps> = ({\n media,\n alt: altFromProps,\n fill,\n sizes,\n priority,\n loading: loadingFromProps,\n style: styleFromProps,\n ...props\n}) => {\n const loading = priority ? undefined : (loadingFromProps ?? 'lazy')\n\n if (typeof media === 'string') {\n return (\n <NextImage\n {...props}\n src={media}\n alt={altFromProps || ''}\n quality={80}\n fill={fill}\n sizes={sizes}\n style={{ objectFit: 'cover', objectPosition: 'center', ...styleFromProps }}\n priority={priority}\n loading={loading}\n />\n )\n }\n\n const width = media.width ?? undefined\n const height = media.height ?? undefined\n const alt = altFromProps || (media as any).alt || media.filename || ''\n const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : ''\n\n const optimizerProps = getImageOptimizerProps(media)\n\n return (\n <NextImage\n {...props}\n src={src}\n alt={alt}\n quality={80}\n fill={fill}\n width={!fill ? width : undefined}\n height={!fill ? height : undefined}\n sizes={sizes}\n style={{ objectFit: 'cover', ...optimizerProps.style, ...styleFromProps }}\n placeholder={optimizerProps.placeholder}\n blurDataURL={optimizerProps.blurDataURL}\n priority={priority}\n loading={loading}\n />\n )\n}\n"],"names":["React","NextImage","getImageOptimizerProps","ImageBox","media","alt","altFromProps","fill","sizes","priority","loading","loadingFromProps","style","styleFromProps","props","undefined","src","quality","objectFit","objectPosition","width","height","filename","url","updatedAt","optimizerProps","placeholder","blurDataURL"],"mappings":"AAAA;;AAEA,OAAOA,WAAW,QAAO;AACzB,OAAOC,eAAoC,aAAY;AAEvD,SAASC,sBAAsB,QAAQ,yCAAwC;AAO/E,OAAO,MAAMC,WAAoC,CAAC,EAChDC,KAAK,EACLC,KAAKC,YAAY,EACjBC,IAAI,EACJC,KAAK,EACLC,QAAQ,EACRC,SAASC,gBAAgB,EACzBC,OAAOC,cAAc,EACrB,GAAGC,OACJ;IACC,MAAMJ,UAAUD,WAAWM,YAAaJ,oBAAoB;IAE5D,IAAI,OAAOP,UAAU,UAAU;QAC7B,qBACE,KAACH;YACE,GAAGa,KAAK;YACTE,KAAKZ;YACLC,KAAKC,gBAAgB;YACrBW,SAAS;YACTV,MAAMA;YACNC,OAAOA;YACPI,OAAO;gBAAEM,WAAW;gBAASC,gBAAgB;gBAAU,GAAGN,cAAc;YAAC;YACzEJ,UAAUA;YACVC,SAASA;;IAGf;IAEA,MAAMU,QAAQhB,MAAMgB,KAAK,IAAIL;IAC7B,MAAMM,SAASjB,MAAMiB,MAAM,IAAIN;IAC/B,MAAMV,MAAMC,gBAAgB,AAACF,MAAcC,GAAG,IAAID,MAAMkB,QAAQ,IAAI;IACpE,MAAMN,MAAMZ,MAAMmB,GAAG,GAAG,GAAGnB,MAAMmB,GAAG,GAAGnB,MAAMoB,SAAS,GAAG,CAAC,CAAC,EAAEpB,MAAMoB,SAAS,EAAE,GAAG,IAAI,GAAG;IAExF,MAAMC,iBAAiBvB,uBAAuBE;IAE9C,qBACE,KAACH;QACE,GAAGa,KAAK;QACTE,KAAKA;QACLX,KAAKA;QACLY,SAAS;QACTV,MAAMA;QACNa,OAAO,CAACb,OAAOa,QAAQL;QACvBM,QAAQ,CAACd,OAAOc,SAASN;QACzBP,OAAOA;QACPI,OAAO;YAAEM,WAAW;YAAS,GAAGO,eAAeb,KAAK;YAAE,GAAGC,cAAc;QAAC;QACxEa,aAAaD,eAAeC,WAAW;QACvCC,aAAaF,eAAeE,WAAW;QACvClB,UAAUA;QACVC,SAASA;;AAGf,EAAC"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { thumbHashToDataURL } from 'thumbhash';
|
|
5
|
-
import { useAllFormFields } from '@payloadcms/ui';
|
|
5
|
+
import { useAllFormFields, useDocumentInfo } from '@payloadcms/ui';
|
|
6
6
|
const formatBytes = (bytes)=>{
|
|
7
7
|
if (bytes === 0) return '0 B';
|
|
8
8
|
const k = 1024;
|
|
@@ -21,14 +21,71 @@ const statusColors = {
|
|
|
21
21
|
complete: '#10b981',
|
|
22
22
|
error: '#ef4444'
|
|
23
23
|
};
|
|
24
|
+
const POLL_INTERVAL_MS = 2000;
|
|
24
25
|
export const OptimizationStatus = (props)=>{
|
|
25
26
|
const [formState] = useAllFormFields();
|
|
27
|
+
const { collectionSlug, id } = useDocumentInfo();
|
|
26
28
|
const basePath = props.path ?? 'imageOptimizer';
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
29
|
+
const formStatus = formState[`${basePath}.status`]?.value;
|
|
30
|
+
const formOriginalSize = formState[`${basePath}.originalSize`]?.value;
|
|
31
|
+
const formOptimizedSize = formState[`${basePath}.optimizedSize`]?.value;
|
|
32
|
+
const formThumbHash = formState[`${basePath}.thumbHash`]?.value;
|
|
33
|
+
const formError = formState[`${basePath}.error`]?.value;
|
|
34
|
+
const [polledData, setPolledData] = React.useState(null);
|
|
35
|
+
// Reset polled data when a new upload changes the form status back to pending
|
|
36
|
+
React.useEffect(()=>{
|
|
37
|
+
if (formStatus === 'pending') {
|
|
38
|
+
setPolledData(null);
|
|
39
|
+
}
|
|
40
|
+
}, [
|
|
41
|
+
formStatus
|
|
42
|
+
]);
|
|
43
|
+
// Poll for status updates when status is non-terminal
|
|
44
|
+
React.useEffect(()=>{
|
|
45
|
+
const currentStatus = polledData?.status ?? formStatus;
|
|
46
|
+
if (!currentStatus || currentStatus === 'complete' || currentStatus === 'error') return;
|
|
47
|
+
if (!collectionSlug || !id) return;
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const poll = async ()=>{
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`/api/${collectionSlug}/${id}?depth=0`, {
|
|
52
|
+
signal: controller.signal
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) return;
|
|
55
|
+
const doc = await res.json();
|
|
56
|
+
const optimizer = doc.imageOptimizer;
|
|
57
|
+
if (!optimizer) return;
|
|
58
|
+
setPolledData({
|
|
59
|
+
status: optimizer.status,
|
|
60
|
+
originalSize: optimizer.originalSize,
|
|
61
|
+
optimizedSize: optimizer.optimizedSize,
|
|
62
|
+
thumbHash: optimizer.thumbHash,
|
|
63
|
+
error: optimizer.error,
|
|
64
|
+
variants: optimizer.variants
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// Silently ignore fetch errors (abort, network issues)
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const intervalId = setInterval(poll, POLL_INTERVAL_MS);
|
|
71
|
+
// Run immediately on mount
|
|
72
|
+
poll();
|
|
73
|
+
return ()=>{
|
|
74
|
+
controller.abort();
|
|
75
|
+
clearInterval(intervalId);
|
|
76
|
+
};
|
|
77
|
+
}, [
|
|
78
|
+
polledData?.status,
|
|
79
|
+
formStatus,
|
|
80
|
+
collectionSlug,
|
|
81
|
+
id
|
|
82
|
+
]);
|
|
83
|
+
// Use polled data when available, otherwise fall back to form state
|
|
84
|
+
const status = polledData?.status ?? formStatus;
|
|
85
|
+
const originalSize = polledData?.originalSize ?? formOriginalSize;
|
|
86
|
+
const optimizedSize = polledData?.optimizedSize ?? formOptimizedSize;
|
|
87
|
+
const thumbHash = polledData?.thumbHash ?? formThumbHash;
|
|
88
|
+
const error = polledData?.error ?? formError;
|
|
32
89
|
const thumbHashUrl = React.useMemo(()=>{
|
|
33
90
|
if (!thumbHash) return null;
|
|
34
91
|
try {
|
|
@@ -40,19 +97,27 @@ export const OptimizationStatus = (props)=>{
|
|
|
40
97
|
}, [
|
|
41
98
|
thumbHash
|
|
42
99
|
]);
|
|
43
|
-
// Read variants
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
100
|
+
// Read variants from polled data or form state
|
|
101
|
+
const variants = React.useMemo(()=>{
|
|
102
|
+
if (polledData?.variants) return polledData.variants;
|
|
103
|
+
const variantsField = formState[`${basePath}.variants`];
|
|
104
|
+
const rowCount = variantsField?.rows?.length ?? 0;
|
|
105
|
+
const formVariants = [];
|
|
106
|
+
for(let i = 0; i < rowCount; i++){
|
|
107
|
+
formVariants.push({
|
|
108
|
+
format: formState[`${basePath}.variants.${i}.format`]?.value,
|
|
109
|
+
filename: formState[`${basePath}.variants.${i}.filename`]?.value,
|
|
110
|
+
filesize: formState[`${basePath}.variants.${i}.filesize`]?.value,
|
|
111
|
+
width: formState[`${basePath}.variants.${i}.width`]?.value,
|
|
112
|
+
height: formState[`${basePath}.variants.${i}.height`]?.value
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return formVariants;
|
|
116
|
+
}, [
|
|
117
|
+
polledData?.variants,
|
|
118
|
+
formState,
|
|
119
|
+
basePath
|
|
120
|
+
]);
|
|
56
121
|
if (!status) {
|
|
57
122
|
return /*#__PURE__*/ _jsx("div", {
|
|
58
123
|
style: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/OptimizationStatus.tsx"],"sourcesContent":["'use client'\n\nimport React from 'react'\nimport { thumbHashToDataURL } from 'thumbhash'\nimport { useAllFormFields } from '@payloadcms/ui'\n\nconst formatBytes = (bytes: number): string => {\n if (bytes === 0) return '0 B'\n const k = 1024\n const sizes = ['B', 'KB', 'MB', 'GB']\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`\n}\n\nconst statusColors: Record<string, string> = {\n pending: '#f59e0b',\n processing: '#3b82f6',\n complete: '#10b981',\n error: '#ef4444',\n}\n\nexport const OptimizationStatus: React.FC<{ path?: string }> = (props) => {\n const [formState] = useAllFormFields()\n const basePath = props.path ?? 'imageOptimizer'\n\n const status = formState[`${basePath}.status`]?.value as string | undefined\n const originalSize = formState[`${basePath}.originalSize`]?.value as number | undefined\n const optimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined\n const thumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined\n const error = formState[`${basePath}.error`]?.value as string | undefined\n\n const thumbHashUrl = React.useMemo(() => {\n if (!thumbHash) return null\n try {\n const bytes = Uint8Array.from(atob(thumbHash), c => c.charCodeAt(0))\n return thumbHashToDataURL(bytes)\n } catch {\n return null\n }\n }, [thumbHash])\n\n // Read variants array from form state\n const variantsField = formState[`${basePath}.variants`]\n const rowCount = (variantsField as any)?.rows?.length ?? 0\n const variants: Array<{\n format?: string\n filename?: string\n filesize?: number\n width?: number\n height?: number\n }> = []\n\n for (let i = 0; i < rowCount; i++) {\n variants.push({\n format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,\n filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,\n filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,\n width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,\n height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,\n })\n }\n\n if (!status) {\n return (\n <div style={{ padding: '12px 0' }}>\n <div style={{ color: '#6b7280', fontSize: '13px' }}>\n No optimization data yet. Upload an image to optimize.\n </div>\n </div>\n )\n }\n\n const savings =\n originalSize && optimizedSize\n ? Math.round((1 - optimizedSize / originalSize) * 100)\n : null\n\n return (\n <div style={{ padding: '12px 0' }}>\n <div style={{ marginBottom: '8px' }}>\n <span\n style={{\n backgroundColor: statusColors[status] || '#6b7280',\n borderRadius: '4px',\n color: '#fff',\n display: 'inline-block',\n fontSize: '12px',\n fontWeight: 600,\n padding: '2px 8px',\n textTransform: 'uppercase',\n }}\n >\n {status}\n </span>\n </div>\n\n {error && (\n <div style={{ color: '#ef4444', fontSize: '13px', marginBottom: '8px' }}>{error}</div>\n )}\n\n {originalSize != null && optimizedSize != null && (\n <div style={{ fontSize: '13px', marginBottom: '8px' }}>\n <div>Original: <strong>{formatBytes(originalSize)}</strong></div>\n <div>\n Optimized: <strong>{formatBytes(optimizedSize)}</strong>\n {savings != null && savings > 0 && (\n <span style={{ color: '#10b981', marginLeft: '4px' }}>(-{savings}%)</span>\n )}\n </div>\n </div>\n )}\n\n {thumbHashUrl && (\n <div style={{ marginBottom: '8px' }}>\n <div style={{ fontSize: '12px', marginBottom: '4px', opacity: 0.7 }}>Blur Preview</div>\n <img\n alt=\"Blur placeholder\"\n src={thumbHashUrl}\n style={{ borderRadius: '4px', height: '40px', width: 'auto' }}\n />\n </div>\n )}\n\n {variants.length > 0 && (\n <div>\n <div style={{ fontSize: '12px', marginBottom: '4px', opacity: 0.7 }}>Variants</div>\n {variants.map((v, i) => (\n <div key={i} style={{ fontSize: '12px', marginBottom: '2px' }}>\n <strong>{v.format?.toUpperCase()}</strong> — {v.filesize ? formatBytes(v.filesize) : '?'}{' '}\n ({v.width}x{v.height})\n </div>\n ))}\n </div>\n )}\n </div>\n )\n}\n"],"names":["React","thumbHashToDataURL","useAllFormFields","formatBytes","bytes","k","sizes","i","Math","floor","log","parseFloat","pow","toFixed","statusColors","pending","processing","complete","error","OptimizationStatus","props","formState","basePath","path","status","value","originalSize","optimizedSize","thumbHash","thumbHashUrl","useMemo","Uint8Array","from","atob","c","charCodeAt","variantsField","rowCount","rows","length","variants","push","format","filename","filesize","width","height","div","style","padding","color","fontSize","savings","round","marginBottom","span","backgroundColor","borderRadius","display","fontWeight","textTransform","strong","marginLeft","opacity","img","alt","src","map","v","toUpperCase"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/components/OptimizationStatus.tsx"],"sourcesContent":["'use client'\n\nimport React from 'react'\nimport { thumbHashToDataURL } from 'thumbhash'\nimport { useAllFormFields, useDocumentInfo } from '@payloadcms/ui'\n\nconst formatBytes = (bytes: number): string => {\n if (bytes === 0) return '0 B'\n const k = 1024\n const sizes = ['B', 'KB', 'MB', 'GB']\n const i = Math.floor(Math.log(bytes) / Math.log(k))\n return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`\n}\n\nconst statusColors: Record<string, string> = {\n pending: '#f59e0b',\n processing: '#3b82f6',\n complete: '#10b981',\n error: '#ef4444',\n}\n\nconst POLL_INTERVAL_MS = 2000\n\ntype PolledData = {\n status?: string\n originalSize?: number\n optimizedSize?: number\n thumbHash?: string\n error?: string\n variants?: Array<{\n format?: string\n filename?: string\n filesize?: number\n width?: number\n height?: number\n }>\n}\n\nexport const OptimizationStatus: React.FC<{ path?: string }> = (props) => {\n const [formState] = useAllFormFields()\n const { collectionSlug, id } = useDocumentInfo()\n const basePath = props.path ?? 'imageOptimizer'\n\n const formStatus = formState[`${basePath}.status`]?.value as string | undefined\n const formOriginalSize = formState[`${basePath}.originalSize`]?.value as number | undefined\n const formOptimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined\n const formThumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined\n const formError = formState[`${basePath}.error`]?.value as string | undefined\n\n const [polledData, setPolledData] = React.useState<PolledData | null>(null)\n\n // Reset polled data when a new upload changes the form status back to pending\n React.useEffect(() => {\n if (formStatus === 'pending') {\n setPolledData(null)\n }\n }, [formStatus])\n\n // Poll for status updates when status is non-terminal\n React.useEffect(() => {\n const currentStatus = polledData?.status ?? formStatus\n if (!currentStatus || currentStatus === 'complete' || currentStatus === 'error') return\n if (!collectionSlug || !id) return\n\n const controller = new AbortController()\n\n const poll = async () => {\n try {\n const res = await fetch(`/api/${collectionSlug}/${id}?depth=0`, {\n signal: controller.signal,\n })\n if (!res.ok) return\n const doc = await res.json()\n const optimizer = doc.imageOptimizer\n if (!optimizer) return\n\n setPolledData({\n status: optimizer.status,\n originalSize: optimizer.originalSize,\n optimizedSize: optimizer.optimizedSize,\n thumbHash: optimizer.thumbHash,\n error: optimizer.error,\n variants: optimizer.variants,\n })\n } catch {\n // Silently ignore fetch errors (abort, network issues)\n }\n }\n\n const intervalId = setInterval(poll, POLL_INTERVAL_MS)\n // Run immediately on mount\n poll()\n\n return () => {\n controller.abort()\n clearInterval(intervalId)\n }\n }, [polledData?.status, formStatus, collectionSlug, id])\n\n // Use polled data when available, otherwise fall back to form state\n const status = polledData?.status ?? formStatus\n const originalSize = polledData?.originalSize ?? formOriginalSize\n const optimizedSize = polledData?.optimizedSize ?? formOptimizedSize\n const thumbHash = polledData?.thumbHash ?? formThumbHash\n const error = polledData?.error ?? formError\n\n const thumbHashUrl = React.useMemo(() => {\n if (!thumbHash) return null\n try {\n const bytes = Uint8Array.from(atob(thumbHash), c => c.charCodeAt(0))\n return thumbHashToDataURL(bytes)\n } catch {\n return null\n }\n }, [thumbHash])\n\n // Read variants from polled data or form state\n const variants: Array<{\n format?: string\n filename?: string\n filesize?: number\n width?: number\n height?: number\n }> = React.useMemo(() => {\n if (polledData?.variants) return polledData.variants\n\n const variantsField = formState[`${basePath}.variants`]\n const rowCount = (variantsField as any)?.rows?.length ?? 0\n const formVariants: typeof variants = []\n for (let i = 0; i < rowCount; i++) {\n formVariants.push({\n format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,\n filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,\n filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,\n width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,\n height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,\n })\n }\n return formVariants\n }, [polledData?.variants, formState, basePath])\n\n if (!status) {\n return (\n <div style={{ padding: '12px 0' }}>\n <div style={{ color: '#6b7280', fontSize: '13px' }}>\n No optimization data yet. Upload an image to optimize.\n </div>\n </div>\n )\n }\n\n const savings =\n originalSize && optimizedSize\n ? Math.round((1 - optimizedSize / originalSize) * 100)\n : null\n\n return (\n <div style={{ padding: '12px 0' }}>\n <div style={{ marginBottom: '8px' }}>\n <span\n style={{\n backgroundColor: statusColors[status] || '#6b7280',\n borderRadius: '4px',\n color: '#fff',\n display: 'inline-block',\n fontSize: '12px',\n fontWeight: 600,\n padding: '2px 8px',\n textTransform: 'uppercase',\n }}\n >\n {status}\n </span>\n </div>\n\n {error && (\n <div style={{ color: '#ef4444', fontSize: '13px', marginBottom: '8px' }}>{error}</div>\n )}\n\n {originalSize != null && optimizedSize != null && (\n <div style={{ fontSize: '13px', marginBottom: '8px' }}>\n <div>Original: <strong>{formatBytes(originalSize)}</strong></div>\n <div>\n Optimized: <strong>{formatBytes(optimizedSize)}</strong>\n {savings != null && savings > 0 && (\n <span style={{ color: '#10b981', marginLeft: '4px' }}>(-{savings}%)</span>\n )}\n </div>\n </div>\n )}\n\n {thumbHashUrl && (\n <div style={{ marginBottom: '8px' }}>\n <div style={{ fontSize: '12px', marginBottom: '4px', opacity: 0.7 }}>Blur Preview</div>\n <img\n alt=\"Blur placeholder\"\n src={thumbHashUrl}\n style={{ borderRadius: '4px', height: '40px', width: 'auto' }}\n />\n </div>\n )}\n\n {variants.length > 0 && (\n <div>\n <div style={{ fontSize: '12px', marginBottom: '4px', opacity: 0.7 }}>Variants</div>\n {variants.map((v, i) => (\n <div key={i} style={{ fontSize: '12px', marginBottom: '2px' }}>\n <strong>{v.format?.toUpperCase()}</strong> — {v.filesize ? formatBytes(v.filesize) : '?'}{' '}\n ({v.width}x{v.height})\n </div>\n ))}\n </div>\n )}\n </div>\n )\n}\n"],"names":["React","thumbHashToDataURL","useAllFormFields","useDocumentInfo","formatBytes","bytes","k","sizes","i","Math","floor","log","parseFloat","pow","toFixed","statusColors","pending","processing","complete","error","POLL_INTERVAL_MS","OptimizationStatus","props","formState","collectionSlug","id","basePath","path","formStatus","value","formOriginalSize","formOptimizedSize","formThumbHash","formError","polledData","setPolledData","useState","useEffect","currentStatus","status","controller","AbortController","poll","res","fetch","signal","ok","doc","json","optimizer","imageOptimizer","originalSize","optimizedSize","thumbHash","variants","intervalId","setInterval","abort","clearInterval","thumbHashUrl","useMemo","Uint8Array","from","atob","c","charCodeAt","variantsField","rowCount","rows","length","formVariants","push","format","filename","filesize","width","height","div","style","padding","color","fontSize","savings","round","marginBottom","span","backgroundColor","borderRadius","display","fontWeight","textTransform","strong","marginLeft","opacity","img","alt","src","map","v","toUpperCase"],"mappings":"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"}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
import type { GroupField } from 'payload';
|
|
2
|
-
|
|
1
|
+
import type { Field, GroupField } from 'payload';
|
|
2
|
+
import type { FieldsOverride } from '../types.js';
|
|
3
|
+
export declare const defaultImageOptimizerFields: Field[];
|
|
4
|
+
export declare const getImageOptimizerField: (fieldsOverride?: FieldsOverride) => GroupField;
|