@inoo-ch/payload-image-optimizer 1.0.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/LICENSE +21 -0
- package/README.md +226 -0
- package/dist/components/ImageBox.d.ts +22 -0
- package/dist/components/ImageBox.js +51 -0
- package/dist/components/ImageBox.js.map +1 -0
- package/dist/components/OptimizationStatus.d.ts +4 -0
- package/dist/components/OptimizationStatus.js +196 -0
- package/dist/components/OptimizationStatus.js.map +1 -0
- package/dist/components/RegenerationButton.d.ts +2 -0
- package/dist/components/RegenerationButton.js +212 -0
- package/dist/components/RegenerationButton.js.map +1 -0
- package/dist/defaults.d.ts +3 -0
- package/dist/defaults.js +35 -0
- package/dist/defaults.js.map +1 -0
- package/dist/endpoints/regenerate.d.ts +4 -0
- package/dist/endpoints/regenerate.js +144 -0
- package/dist/endpoints/regenerate.js.map +1 -0
- package/dist/exports/client.d.ts +6 -0
- package/dist/exports/client.js +6 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/exports/rsc.js +3 -0
- package/dist/exports/rsc.js.map +1 -0
- package/dist/fields/imageOptimizerField.d.ts +2 -0
- package/dist/fields/imageOptimizerField.js +75 -0
- package/dist/fields/imageOptimizerField.js.map +1 -0
- package/dist/hooks/afterChange.d.ts +3 -0
- package/dist/hooks/afterChange.js +40 -0
- package/dist/hooks/afterChange.js.map +1 -0
- package/dist/hooks/beforeChange.d.ts +3 -0
- package/dist/hooks/beforeChange.js +26 -0
- package/dist/hooks/beforeChange.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +127 -0
- package/dist/index.js.map +1 -0
- package/dist/next-image.d.js +2 -0
- package/dist/next-image.d.js.map +1 -0
- package/dist/processing/index.d.ts +17 -0
- package/dist/processing/index.js +46 -0
- package/dist/processing/index.js.map +1 -0
- package/dist/tasks/convertFormats.d.ts +12 -0
- package/dist/tasks/convertFormats.js +84 -0
- package/dist/tasks/convertFormats.js.map +1 -0
- package/dist/tasks/regenerateDocument.d.ts +18 -0
- package/dist/tasks/regenerateDocument.js +121 -0
- package/dist/tasks/regenerateDocument.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utilities/getImageOptimizerProps.d.ts +32 -0
- package/dist/utilities/getImageOptimizerProps.js +55 -0
- package/dist/utilities/getImageOptimizerProps.js.map +1 -0
- package/dist/utilities/thumbhash.d.ts +2 -0
- package/dist/utilities/thumbhash.js +11 -0
- package/dist/utilities/thumbhash.js.map +1 -0
- package/package.json +141 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 inoo.ch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# @inoo-ch/payload-image-optimizer
|
|
2
|
+
|
|
3
|
+
A [Payload CMS](https://payloadcms.com) plugin for automatic image optimization. Converts uploads to WebP/AVIF, resizes to configurable limits, strips EXIF metadata, generates [ThumbHash](https://evanw.github.io/thumbhash/) blur placeholders, and provides bulk regeneration from the admin panel.
|
|
4
|
+
|
|
5
|
+
Built and maintained by [inoo.ch](https://inoo.ch) — a Swiss digital agency crafting modern web experiences.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Format conversion** — Automatically generates WebP and AVIF variants with configurable quality
|
|
10
|
+
- **Smart resizing** — Constrains images to max dimensions while preserving aspect ratio
|
|
11
|
+
- **EXIF stripping** — Removes metadata for smaller files and better privacy
|
|
12
|
+
- **ThumbHash placeholders** — Generates tiny blur hashes for instant image previews
|
|
13
|
+
- **Bulk regeneration** — Re-process existing images from the admin UI with progress tracking
|
|
14
|
+
- **Per-collection config** — Override formats, quality, and dimensions per collection
|
|
15
|
+
- **Admin UI** — Status badges, file size savings, and blur previews in the sidebar
|
|
16
|
+
- **ImageBox component** — Drop-in Next.js `<Image>` wrapper with automatic ThumbHash blur
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Payload CMS `^3.37.0`
|
|
21
|
+
- Next.js `^14.0.0` or `^15.0.0`
|
|
22
|
+
- React `^18.0.0` or `^19.0.0`
|
|
23
|
+
- Node.js `^18.20.2` or `>=20.9.0`
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm add @inoo-ch/payload-image-optimizer
|
|
29
|
+
# or
|
|
30
|
+
npm install @inoo-ch/payload-image-optimizer
|
|
31
|
+
# or
|
|
32
|
+
yarn add @inoo-ch/payload-image-optimizer
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> **Note:** This plugin uses [sharp](https://sharp.pixelplumbing.com/) for image processing. It is expected as a peer dependency from Payload CMS — no separate install needed.
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
Add the plugin to your `payload.config.ts`:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { buildConfig } from 'payload'
|
|
43
|
+
import { imageOptimizer } from '@inoo-ch/payload-image-optimizer'
|
|
44
|
+
|
|
45
|
+
export default buildConfig({
|
|
46
|
+
// ...
|
|
47
|
+
plugins: [
|
|
48
|
+
imageOptimizer({
|
|
49
|
+
collections: {
|
|
50
|
+
media: true,
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's it. Every image uploaded to the `media` collection will be automatically optimized with sensible defaults.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
### Full Example
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
imageOptimizer({
|
|
65
|
+
collections: {
|
|
66
|
+
media: {
|
|
67
|
+
formats: [
|
|
68
|
+
{ format: 'webp', quality: 90 },
|
|
69
|
+
{ format: 'avif', quality: 75 },
|
|
70
|
+
],
|
|
71
|
+
maxDimensions: { width: 4096, height: 4096 },
|
|
72
|
+
},
|
|
73
|
+
avatars: true, // uses global defaults
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// Global defaults (overridden by per-collection config)
|
|
77
|
+
formats: [
|
|
78
|
+
{ format: 'webp', quality: 80 },
|
|
79
|
+
{ format: 'avif', quality: 65 },
|
|
80
|
+
],
|
|
81
|
+
maxDimensions: { width: 2560, height: 2560 },
|
|
82
|
+
generateThumbHash: true,
|
|
83
|
+
stripMetadata: true,
|
|
84
|
+
disabled: false,
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Options
|
|
89
|
+
|
|
90
|
+
| Option | Type | Default | Description |
|
|
91
|
+
|---|---|---|---|
|
|
92
|
+
| `collections` | `Record<string, true \| CollectionConfig>` | *required* | Collections to optimize. Use `true` for defaults or an object for overrides. |
|
|
93
|
+
| `formats` | `FormatQuality[]` | `[{ format: 'webp', quality: 80 }, { format: 'avif', quality: 65 }]` | Output formats and quality (1-100). |
|
|
94
|
+
| `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions. Images are resized to fit within these bounds. |
|
|
95
|
+
| `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholders for instant image previews. |
|
|
96
|
+
| `stripMetadata` | `boolean` | `true` | Remove EXIF and other metadata from images. |
|
|
97
|
+
| `disabled` | `boolean` | `false` | Disable optimization while keeping schema fields intact. |
|
|
98
|
+
|
|
99
|
+
### Per-Collection Overrides
|
|
100
|
+
|
|
101
|
+
Each collection can override `formats` and `maxDimensions`:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
collections: {
|
|
105
|
+
// Hero images: higher quality, larger dimensions
|
|
106
|
+
heroes: {
|
|
107
|
+
formats: [{ format: 'webp', quality: 95 }],
|
|
108
|
+
maxDimensions: { width: 3840, height: 2160 },
|
|
109
|
+
},
|
|
110
|
+
// Thumbnails: smaller, more aggressive compression
|
|
111
|
+
thumbnails: {
|
|
112
|
+
formats: [
|
|
113
|
+
{ format: 'webp', quality: 60 },
|
|
114
|
+
{ format: 'avif', quality: 45 },
|
|
115
|
+
],
|
|
116
|
+
maxDimensions: { width: 800, height: 800 },
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## How It Works
|
|
122
|
+
|
|
123
|
+
1. **Upload** — An image is uploaded to a configured collection
|
|
124
|
+
2. **Pre-process** — The `beforeChange` hook strips metadata, resizes the image, and generates a ThumbHash
|
|
125
|
+
3. **Save** — Payload writes the optimized image to disk
|
|
126
|
+
4. **Convert** — A background job converts the image to WebP/AVIF variants asynchronously
|
|
127
|
+
5. **Done** — The document is updated with variant URLs, file sizes, and optimization status
|
|
128
|
+
|
|
129
|
+
All format conversion runs as async background jobs, so uploads return immediately.
|
|
130
|
+
|
|
131
|
+
## Admin UI
|
|
132
|
+
|
|
133
|
+
The plugin adds an **Optimization Status** panel to the document sidebar showing:
|
|
134
|
+
|
|
135
|
+
- Status badge (pending / processing / complete / error)
|
|
136
|
+
- Original vs. optimized file size with savings percentage
|
|
137
|
+
- ThumbHash blur preview thumbnail
|
|
138
|
+
- List of generated format variants with dimensions and file sizes
|
|
139
|
+
|
|
140
|
+
A **Regenerate Images** button appears in collection list views, allowing you to bulk re-process existing images with a real-time progress bar.
|
|
141
|
+
|
|
142
|
+
## ImageBox Component
|
|
143
|
+
|
|
144
|
+
The plugin exports an `ImageBox` component — a Next.js `<Image>` wrapper that automatically applies ThumbHash blur placeholders:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
|
|
148
|
+
|
|
149
|
+
// Pass a Payload media document directly
|
|
150
|
+
<ImageBox media={doc.heroImage} alt="Hero" />
|
|
151
|
+
|
|
152
|
+
// Or use a plain URL string
|
|
153
|
+
<ImageBox media="/images/photo.jpg" alt="Photo" width={800} height={600} />
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Features:**
|
|
157
|
+
- Automatic ThumbHash `blurDataURL` from the media document
|
|
158
|
+
- Respects Payload focal point (`focalX` / `focalY`) for `objectPosition`
|
|
159
|
+
- Lazy loading by default, with `priority` prop for above-the-fold images
|
|
160
|
+
- Cache busting via `updatedAt` timestamp
|
|
161
|
+
|
|
162
|
+
## Document Schema
|
|
163
|
+
|
|
164
|
+
The plugin adds an `imageOptimizer` field group to each configured collection:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
{
|
|
168
|
+
imageOptimizer: {
|
|
169
|
+
status: 'pending' | 'processing' | 'complete' | 'error',
|
|
170
|
+
originalSize: number, // bytes
|
|
171
|
+
optimizedSize: number, // bytes
|
|
172
|
+
thumbHash: string, // base64-encoded ThumbHash
|
|
173
|
+
error: string, // error message (if failed)
|
|
174
|
+
variants: [
|
|
175
|
+
{
|
|
176
|
+
format: string, // 'webp' | 'avif'
|
|
177
|
+
filename: string, // e.g. 'photo-optimized.webp'
|
|
178
|
+
filesize: number,
|
|
179
|
+
width: number,
|
|
180
|
+
height: number,
|
|
181
|
+
mimeType: string,
|
|
182
|
+
url: string,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## REST API Endpoints
|
|
190
|
+
|
|
191
|
+
### Start Bulk Regeneration
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
POST /api/image-optimizer/regenerate
|
|
195
|
+
Content-Type: application/json
|
|
196
|
+
|
|
197
|
+
{ "collectionSlug": "media", "force": false }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- `force: false` — only regenerates images that are not yet complete
|
|
201
|
+
- `force: true` — re-processes all images from scratch
|
|
202
|
+
|
|
203
|
+
**Response:** `{ "queued": 42, "collectionSlug": "media" }`
|
|
204
|
+
|
|
205
|
+
### Check Regeneration Progress
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
GET /api/image-optimizer/regenerate?collection=media
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Response:** `{ "collectionSlug": "media", "total": 42, "complete": 30, "errored": 1, "pending": 11 }`
|
|
212
|
+
|
|
213
|
+
Both endpoints require an authenticated user.
|
|
214
|
+
|
|
215
|
+
## Contributing
|
|
216
|
+
|
|
217
|
+
This plugin is open source and we welcome community involvement:
|
|
218
|
+
|
|
219
|
+
- **Issues** — Found a bug or have a feature request? [Open an issue](https://github.com/payloadcms-plugins/image-optimizer/issues).
|
|
220
|
+
- **Pull Requests** — PRs are welcome! Please open an issue first to discuss larger changes.
|
|
221
|
+
|
|
222
|
+
All changes are reviewed and merged by the package maintainer at [inoo.ch](https://inoo.ch).
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT - [inoo.ch](https://inoo.ch)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ImageProps } from 'next/image';
|
|
3
|
+
type ImageOptimizerData = {
|
|
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
|
+
};
|
|
17
|
+
export interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {
|
|
18
|
+
media: MediaResource | string;
|
|
19
|
+
alt?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const ImageBox: React.FC<ImageBoxProps>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextImage from 'next/image';
|
|
5
|
+
import { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
|
|
6
|
+
export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, loading: loadingFromProps, style: styleFromProps, ...props })=>{
|
|
7
|
+
const loading = priority ? undefined : loadingFromProps ?? 'lazy';
|
|
8
|
+
if (typeof media === 'string') {
|
|
9
|
+
return /*#__PURE__*/ _jsx(NextImage, {
|
|
10
|
+
...props,
|
|
11
|
+
src: media,
|
|
12
|
+
alt: altFromProps || '',
|
|
13
|
+
quality: 80,
|
|
14
|
+
fill: fill,
|
|
15
|
+
sizes: sizes,
|
|
16
|
+
style: {
|
|
17
|
+
objectFit: 'cover',
|
|
18
|
+
objectPosition: 'center',
|
|
19
|
+
...styleFromProps
|
|
20
|
+
},
|
|
21
|
+
priority: priority,
|
|
22
|
+
loading: loading
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const width = media.width ?? undefined;
|
|
26
|
+
const height = media.height ?? undefined;
|
|
27
|
+
const alt = altFromProps || media.alt || media.filename || '';
|
|
28
|
+
const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : '';
|
|
29
|
+
const optimizerProps = getImageOptimizerProps(media);
|
|
30
|
+
return /*#__PURE__*/ _jsx(NextImage, {
|
|
31
|
+
...props,
|
|
32
|
+
src: src,
|
|
33
|
+
alt: alt,
|
|
34
|
+
quality: 80,
|
|
35
|
+
fill: fill,
|
|
36
|
+
width: !fill ? width : undefined,
|
|
37
|
+
height: !fill ? height : undefined,
|
|
38
|
+
sizes: sizes,
|
|
39
|
+
style: {
|
|
40
|
+
objectFit: 'cover',
|
|
41
|
+
...optimizerProps.style,
|
|
42
|
+
...styleFromProps
|
|
43
|
+
},
|
|
44
|
+
placeholder: optimizerProps.placeholder,
|
|
45
|
+
blurDataURL: optimizerProps.blurDataURL,
|
|
46
|
+
priority: priority,
|
|
47
|
+
loading: loading
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=ImageBox.js.map
|
|
@@ -0,0 +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 { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\n\ntype ImageOptimizerData = {\n thumbHash?: string | null\n}\n\ntype MediaResource = {\n url?: string | null\n alt?: string | null\n width?: number | null\n height?: number | null\n filename?: string | null\n focalX?: number | null\n focalY?: number | null\n imageOptimizer?: ImageOptimizerData | null\n updatedAt?: string\n}\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;AACvD,SAASC,sBAAsB,QAAQ,yCAAwC;AAuB/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"}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { thumbHashToDataURL } from 'thumbhash';
|
|
5
|
+
import { useAllFormFields } from '@payloadcms/ui';
|
|
6
|
+
const formatBytes = (bytes)=>{
|
|
7
|
+
if (bytes === 0) return '0 B';
|
|
8
|
+
const k = 1024;
|
|
9
|
+
const sizes = [
|
|
10
|
+
'B',
|
|
11
|
+
'KB',
|
|
12
|
+
'MB',
|
|
13
|
+
'GB'
|
|
14
|
+
];
|
|
15
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
16
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
17
|
+
};
|
|
18
|
+
const statusColors = {
|
|
19
|
+
pending: '#f59e0b',
|
|
20
|
+
processing: '#3b82f6',
|
|
21
|
+
complete: '#10b981',
|
|
22
|
+
error: '#ef4444'
|
|
23
|
+
};
|
|
24
|
+
export const OptimizationStatus = (props)=>{
|
|
25
|
+
const [formState] = useAllFormFields();
|
|
26
|
+
const basePath = props.path ?? 'imageOptimizer';
|
|
27
|
+
const status = formState[`${basePath}.status`]?.value;
|
|
28
|
+
const originalSize = formState[`${basePath}.originalSize`]?.value;
|
|
29
|
+
const optimizedSize = formState[`${basePath}.optimizedSize`]?.value;
|
|
30
|
+
const thumbHash = formState[`${basePath}.thumbHash`]?.value;
|
|
31
|
+
const error = formState[`${basePath}.error`]?.value;
|
|
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
|
+
}, [
|
|
41
|
+
thumbHash
|
|
42
|
+
]);
|
|
43
|
+
// Read variants array from form state
|
|
44
|
+
const variantsField = formState[`${basePath}.variants`];
|
|
45
|
+
const rowCount = variantsField?.rows?.length ?? 0;
|
|
46
|
+
const variants = [];
|
|
47
|
+
for(let i = 0; i < rowCount; i++){
|
|
48
|
+
variants.push({
|
|
49
|
+
format: formState[`${basePath}.variants.${i}.format`]?.value,
|
|
50
|
+
filename: formState[`${basePath}.variants.${i}.filename`]?.value,
|
|
51
|
+
filesize: formState[`${basePath}.variants.${i}.filesize`]?.value,
|
|
52
|
+
width: formState[`${basePath}.variants.${i}.width`]?.value,
|
|
53
|
+
height: formState[`${basePath}.variants.${i}.height`]?.value
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!status) {
|
|
57
|
+
return /*#__PURE__*/ _jsx("div", {
|
|
58
|
+
style: {
|
|
59
|
+
padding: '12px 0'
|
|
60
|
+
},
|
|
61
|
+
children: /*#__PURE__*/ _jsx("div", {
|
|
62
|
+
style: {
|
|
63
|
+
color: '#6b7280',
|
|
64
|
+
fontSize: '13px'
|
|
65
|
+
},
|
|
66
|
+
children: "No optimization data yet. Upload an image to optimize."
|
|
67
|
+
})
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const savings = originalSize && optimizedSize ? Math.round((1 - optimizedSize / originalSize) * 100) : null;
|
|
71
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
72
|
+
style: {
|
|
73
|
+
padding: '12px 0'
|
|
74
|
+
},
|
|
75
|
+
children: [
|
|
76
|
+
/*#__PURE__*/ _jsx("div", {
|
|
77
|
+
style: {
|
|
78
|
+
marginBottom: '8px'
|
|
79
|
+
},
|
|
80
|
+
children: /*#__PURE__*/ _jsx("span", {
|
|
81
|
+
style: {
|
|
82
|
+
backgroundColor: statusColors[status] || '#6b7280',
|
|
83
|
+
borderRadius: '4px',
|
|
84
|
+
color: '#fff',
|
|
85
|
+
display: 'inline-block',
|
|
86
|
+
fontSize: '12px',
|
|
87
|
+
fontWeight: 600,
|
|
88
|
+
padding: '2px 8px',
|
|
89
|
+
textTransform: 'uppercase'
|
|
90
|
+
},
|
|
91
|
+
children: status
|
|
92
|
+
})
|
|
93
|
+
}),
|
|
94
|
+
error && /*#__PURE__*/ _jsx("div", {
|
|
95
|
+
style: {
|
|
96
|
+
color: '#ef4444',
|
|
97
|
+
fontSize: '13px',
|
|
98
|
+
marginBottom: '8px'
|
|
99
|
+
},
|
|
100
|
+
children: error
|
|
101
|
+
}),
|
|
102
|
+
originalSize != null && optimizedSize != null && /*#__PURE__*/ _jsxs("div", {
|
|
103
|
+
style: {
|
|
104
|
+
fontSize: '13px',
|
|
105
|
+
marginBottom: '8px'
|
|
106
|
+
},
|
|
107
|
+
children: [
|
|
108
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
109
|
+
children: [
|
|
110
|
+
"Original: ",
|
|
111
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
112
|
+
children: formatBytes(originalSize)
|
|
113
|
+
})
|
|
114
|
+
]
|
|
115
|
+
}),
|
|
116
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
117
|
+
children: [
|
|
118
|
+
"Optimized: ",
|
|
119
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
120
|
+
children: formatBytes(optimizedSize)
|
|
121
|
+
}),
|
|
122
|
+
savings != null && savings > 0 && /*#__PURE__*/ _jsxs("span", {
|
|
123
|
+
style: {
|
|
124
|
+
color: '#10b981',
|
|
125
|
+
marginLeft: '4px'
|
|
126
|
+
},
|
|
127
|
+
children: [
|
|
128
|
+
"(-",
|
|
129
|
+
savings,
|
|
130
|
+
"%)"
|
|
131
|
+
]
|
|
132
|
+
})
|
|
133
|
+
]
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
}),
|
|
137
|
+
thumbHashUrl && /*#__PURE__*/ _jsxs("div", {
|
|
138
|
+
style: {
|
|
139
|
+
marginBottom: '8px'
|
|
140
|
+
},
|
|
141
|
+
children: [
|
|
142
|
+
/*#__PURE__*/ _jsx("div", {
|
|
143
|
+
style: {
|
|
144
|
+
fontSize: '12px',
|
|
145
|
+
marginBottom: '4px',
|
|
146
|
+
opacity: 0.7
|
|
147
|
+
},
|
|
148
|
+
children: "Blur Preview"
|
|
149
|
+
}),
|
|
150
|
+
/*#__PURE__*/ _jsx("img", {
|
|
151
|
+
alt: "Blur placeholder",
|
|
152
|
+
src: thumbHashUrl,
|
|
153
|
+
style: {
|
|
154
|
+
borderRadius: '4px',
|
|
155
|
+
height: '40px',
|
|
156
|
+
width: 'auto'
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
]
|
|
160
|
+
}),
|
|
161
|
+
variants.length > 0 && /*#__PURE__*/ _jsxs("div", {
|
|
162
|
+
children: [
|
|
163
|
+
/*#__PURE__*/ _jsx("div", {
|
|
164
|
+
style: {
|
|
165
|
+
fontSize: '12px',
|
|
166
|
+
marginBottom: '4px',
|
|
167
|
+
opacity: 0.7
|
|
168
|
+
},
|
|
169
|
+
children: "Variants"
|
|
170
|
+
}),
|
|
171
|
+
variants.map((v, i)=>/*#__PURE__*/ _jsxs("div", {
|
|
172
|
+
style: {
|
|
173
|
+
fontSize: '12px',
|
|
174
|
+
marginBottom: '2px'
|
|
175
|
+
},
|
|
176
|
+
children: [
|
|
177
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
178
|
+
children: v.format?.toUpperCase()
|
|
179
|
+
}),
|
|
180
|
+
" — ",
|
|
181
|
+
v.filesize ? formatBytes(v.filesize) : '?',
|
|
182
|
+
' ',
|
|
183
|
+
"(",
|
|
184
|
+
v.width,
|
|
185
|
+
"x",
|
|
186
|
+
v.height,
|
|
187
|
+
")"
|
|
188
|
+
]
|
|
189
|
+
}, i))
|
|
190
|
+
]
|
|
191
|
+
})
|
|
192
|
+
]
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
//# sourceMappingURL=OptimizationStatus.js.map
|
|
@@ -0,0 +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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"}
|