@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -0
  3. package/dist/components/ImageBox.d.ts +22 -0
  4. package/dist/components/ImageBox.js +51 -0
  5. package/dist/components/ImageBox.js.map +1 -0
  6. package/dist/components/OptimizationStatus.d.ts +4 -0
  7. package/dist/components/OptimizationStatus.js +196 -0
  8. package/dist/components/OptimizationStatus.js.map +1 -0
  9. package/dist/components/RegenerationButton.d.ts +2 -0
  10. package/dist/components/RegenerationButton.js +212 -0
  11. package/dist/components/RegenerationButton.js.map +1 -0
  12. package/dist/defaults.d.ts +3 -0
  13. package/dist/defaults.js +35 -0
  14. package/dist/defaults.js.map +1 -0
  15. package/dist/endpoints/regenerate.d.ts +4 -0
  16. package/dist/endpoints/regenerate.js +144 -0
  17. package/dist/endpoints/regenerate.js.map +1 -0
  18. package/dist/exports/client.d.ts +6 -0
  19. package/dist/exports/client.js +6 -0
  20. package/dist/exports/client.js.map +1 -0
  21. package/dist/exports/rsc.d.ts +1 -0
  22. package/dist/exports/rsc.js +3 -0
  23. package/dist/exports/rsc.js.map +1 -0
  24. package/dist/fields/imageOptimizerField.d.ts +2 -0
  25. package/dist/fields/imageOptimizerField.js +75 -0
  26. package/dist/fields/imageOptimizerField.js.map +1 -0
  27. package/dist/hooks/afterChange.d.ts +3 -0
  28. package/dist/hooks/afterChange.js +40 -0
  29. package/dist/hooks/afterChange.js.map +1 -0
  30. package/dist/hooks/beforeChange.d.ts +3 -0
  31. package/dist/hooks/beforeChange.js +26 -0
  32. package/dist/hooks/beforeChange.js.map +1 -0
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.js +127 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/next-image.d.js +2 -0
  37. package/dist/next-image.d.js.map +1 -0
  38. package/dist/processing/index.d.ts +17 -0
  39. package/dist/processing/index.js +46 -0
  40. package/dist/processing/index.js.map +1 -0
  41. package/dist/tasks/convertFormats.d.ts +12 -0
  42. package/dist/tasks/convertFormats.js +84 -0
  43. package/dist/tasks/convertFormats.js.map +1 -0
  44. package/dist/tasks/regenerateDocument.d.ts +18 -0
  45. package/dist/tasks/regenerateDocument.js +121 -0
  46. package/dist/tasks/regenerateDocument.js.map +1 -0
  47. package/dist/types.d.ts +35 -0
  48. package/dist/types.js +3 -0
  49. package/dist/types.js.map +1 -0
  50. package/dist/utilities/getImageOptimizerProps.d.ts +32 -0
  51. package/dist/utilities/getImageOptimizerProps.js +55 -0
  52. package/dist/utilities/getImageOptimizerProps.js.map +1 -0
  53. package/dist/utilities/thumbhash.d.ts +2 -0
  54. package/dist/utilities/thumbhash.js +11 -0
  55. package/dist/utilities/thumbhash.js.map +1 -0
  56. 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,4 @@
1
+ import React from 'react';
2
+ export declare const OptimizationStatus: React.FC<{
3
+ path?: string;
4
+ }>;
@@ -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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"}
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const RegenerationButton: React.FC;