@inoo-ch/payload-image-optimizer 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT_DOCS.md CHANGED
@@ -59,7 +59,7 @@ imageOptimizer({
59
59
  stripMetadata: true, // strip EXIF data
60
60
  generateThumbHash: true, // generate blur placeholders
61
61
  replaceOriginal: true, // convert main file to primary format
62
- clientOptimization: false, // pre-resize in browser before upload
62
+ clientOptimization: true, // pre-resize in browser before upload
63
63
  disabled: false, // keep fields but skip all processing
64
64
  })
65
65
  ```
@@ -72,7 +72,7 @@ imageOptimizer({
72
72
  | `stripMetadata` | `boolean` | `true` | Strip EXIF, ICC, XMP metadata. |
73
73
  | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholder. |
74
74
  | `replaceOriginal` | `boolean` | `true` | Replace the original file with the primary format. |
75
- | `clientOptimization` | `boolean` | `false` | Pre-resize images in browser via Canvas API before upload. Reduces upload size 90%+ for large images. |
75
+ | `clientOptimization` | `boolean` | `true` | Pre-resize images in browser via Canvas API before upload. Reduces upload size 90%+ for large images. |
76
76
  | `disabled` | `boolean` | `false` | Keep schema fields but disable all processing. |
77
77
 
78
78
  ### Per-Collection Overrides (`CollectionOptimizerConfig`)
@@ -219,24 +219,24 @@ Get current optimization status for a collection. Requires authentication.
219
219
 
220
220
  Import from `@inoo-ch/payload-image-optimizer/client`:
221
221
 
222
- ### `ImageBox` Component
222
+ ### `ImageBox` Component (Recommended)
223
223
 
224
- Drop-in Next.js `<Image>` wrapper with automatic ThumbHash blur placeholders, focal point support, and smooth fade-in transition.
224
+ Drop-in Next.js `<Image>` wrapper — the easiest way to display images with best practices. Automatically handles ThumbHash blur placeholders, focal point positioning, smooth fade-in, responsive variant loading, and smart `sizes` defaults.
225
225
 
226
226
  ```tsx
227
227
  import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
228
228
 
229
- // With a Payload media resource object
230
- <ImageBox media={doc.image} alt="Hero" fill sizes="100vw" />
229
+ // Pass the full Payload media document — ImageBox handles everything
230
+ <ImageBox media={doc.heroImage} alt="Hero" fill priority />
231
231
 
232
- // With a plain URL string
233
- <ImageBox media="/images/fallback.jpg" alt="Fallback" width={800} height={600} />
232
+ // Card grid explicit sizes hint for responsive loading
233
+ <ImageBox media={doc.image} alt="Card" fill sizes="(max-width: 768px) 100vw, 33vw" />
234
234
 
235
- // Disable fade animation
236
- <ImageBox media={doc.image} alt="Photo" fade={false} />
235
+ // Fixed dimensions (non-fill)
236
+ <ImageBox media={doc.avatar} alt="Avatar" width={64} height={64} fade={false} />
237
237
 
238
- // Custom fade duration
239
- <ImageBox media={doc.image} alt="Photo" fadeDuration={300} />
238
+ // Plain URL string fallback
239
+ <ImageBox media="/images/fallback.jpg" alt="Fallback" width={800} height={600} />
240
240
  ```
241
241
 
242
242
  **Props:** Extends all Next.js `ImageProps` (except `src`), plus:
@@ -248,45 +248,92 @@ import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
248
248
  | `fade` | `boolean` | `true` | Enable smooth blur-to-sharp fade transition on load |
249
249
  | `fadeDuration` | `number` | `500` | Duration of the fade animation in milliseconds |
250
250
 
251
- Automatically applies:
252
- - ThumbHash blur placeholder (if available on the media resource)
253
- - Smooth blur-to-sharp fade transition on image load (disable with `fade={false}`)
254
- - Focal point positioning via `objectPosition` (using `focalX`/`focalY`)
255
- - Cache-busting via `updatedAt` query parameter
256
- - `objectFit: 'cover'` by default (overridable via `style`)
251
+ **What ImageBox does automatically:**
257
252
 
258
- ### `FadeImage` Component
253
+ - **ThumbHash blur placeholder** — per-image blur preview from `imageOptimizer.thumbHash`
254
+ - **Responsive variant loader** — when `media.sizes` has Payload size variants (from `imageSizes` collection config), serves pre-generated variants directly instead of going through `/_next/image` re-optimization. Falls back to `/_next/image` when no close match exists.
255
+ - **Smart `sizes` default** — for `fill` mode without explicit `sizes`, uses `(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw` instead of the browser's `100vw` assumption
256
+ - **Focal point positioning** — applies `objectPosition` from `focalX`/`focalY`
257
+ - **Fade transition** — smooth blur-to-sharp animation on load
258
+ - **Cache busting** — appends `updatedAt` as query parameter
259
259
 
260
- Standalone Next.js `<Image>` wrapper with fade-in transition for use with `getImageOptimizerProps()`. Use this when you have a custom image component and want the fade effect without `ImageBox`.
260
+ **Important:** For the variant loader to work, your collection must have `imageSizes` configured in the Payload collection config. This is how Payload generates the width variants. Without `imageSizes`, images still work but go through `/_next/image` as usual.
261
+
262
+ ### `getOptimizedImageProps()` — For Existing Components (e.g., Payload Website Template)
263
+
264
+ Single-function integration for existing `<NextImage>` components. Returns ThumbHash, focal point, AND variant loader in one spread-friendly object. **This is the recommended way to integrate with the Payload website template's `ImageMedia` component.**
261
265
 
262
266
  ```tsx
263
- import { FadeImage, getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
267
+ import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
264
268
 
265
- const optimizerProps = getImageOptimizerProps(resource)
269
+ // In your existing ImageMedia component — 3 lines to add:
270
+ const optimizedProps = getOptimizedImageProps(resource)
266
271
 
267
- <FadeImage
268
- src={resource.url}
269
- alt=""
270
- width={800}
271
- height={600}
272
- optimizerProps={optimizerProps}
272
+ <NextImage
273
+ {...optimizedProps} // spreads: placeholder, blurDataURL, style, loader
274
+ src={src}
275
+ alt={alt}
276
+ fill={fill}
277
+ sizes={sizes}
278
+ quality={80}
279
+ priority={priority}
280
+ loading={loading}
273
281
  />
274
282
  ```
275
283
 
276
- **Props:** Extends all Next.js `ImageProps` (except `placeholder`, `blurDataURL`, `onLoad`), plus:
284
+ **Returns:**
285
+ ```ts
286
+ {
287
+ placeholder: 'blur' | 'empty',
288
+ blurDataURL?: string, // data URL from ThumbHash
289
+ style: { objectPosition: string }, // from focalX/focalY
290
+ loader?: ImageLoader, // variant-aware loader (only when media.sizes has variants)
291
+ }
292
+ ```
277
293
 
278
- | Prop | Type | Default | Description |
279
- |------|------|---------|-------------|
280
- | `optimizerProps` | `ImageOptimizerProps` | | Props returned by `getImageOptimizerProps()` |
281
- | `fadeDuration` | `number` | `500` | Duration of the fade animation in milliseconds |
294
+ **Payload Website Template integration example:**
295
+
296
+ If you're using the [Payload website template](https://github.com/payloadcms/payload/tree/main/templates/website), modify `src/components/Media/ImageMedia/index.tsx`:
297
+
298
+ ```diff
299
+ + import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
300
+
301
+ export const ImageMedia: React.FC<MediaProps> = (props) => {
302
+ // ... existing code ...
303
+
304
+ + const optimizedProps = typeof resource === 'object' ? getOptimizedImageProps(resource) : {}
305
+
306
+ return (
307
+ <picture className={cn(pictureClassName)}>
308
+ <NextImage
309
+ + {...optimizedProps}
310
+ alt={alt || ''}
311
+ className={cn(imgClassName)}
312
+ fill={fill}
313
+ height={!fill ? height : undefined}
314
+ - placeholder="blur"
315
+ - blurDataURL={placeholderBlur}
316
+ priority={priority}
317
+ - quality={100}
318
+ + quality={80}
319
+ loading={loading}
320
+ sizes={sizes}
321
+ src={src}
322
+ width={!fill ? width : undefined}
323
+ />
324
+ </picture>
325
+ )
326
+ }
327
+ ```
282
328
 
283
- ### `getImageOptimizerProps()` Utility
329
+ This replaces the template's hardcoded blur placeholder with per-image ThumbHash, adds focal point support, and enables variant-aware responsive loading — all in a few lines.
284
330
 
285
- For integrating with existing image components (e.g., the Payload website template's `ImageMedia`):
331
+ ### `getImageOptimizerProps()` Low-Level Utility
332
+
333
+ Returns only ThumbHash placeholder and focal point props (no variant loader). Use when you need granular control or don't want the variant loader.
286
334
 
287
335
  ```tsx
288
336
  import { getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
289
- import NextImage from 'next/image'
290
337
 
291
338
  const optimizerProps = getImageOptimizerProps(media)
292
339
 
@@ -301,13 +348,76 @@ const optimizerProps = getImageOptimizerProps(media)
301
348
  ```ts
302
349
  {
303
350
  placeholder: 'blur' | 'empty',
304
- blurDataURL?: string, // data URL from ThumbHash (only when placeholder is 'blur')
305
- style: {
306
- objectPosition: string, // e.g. '50% 30%' from focalX/focalY, or 'center'
307
- },
351
+ blurDataURL?: string,
352
+ style: { objectPosition: string },
308
353
  }
309
354
  ```
310
355
 
356
+ ### `createVariantLoader()` — Custom Loader Factory
357
+
358
+ Creates a Next.js Image `loader` that maps requested widths to pre-generated Payload size variants. Use when building fully custom image components.
359
+
360
+ ```tsx
361
+ import { createVariantLoader } from '@inoo-ch/payload-image-optimizer/client'
362
+
363
+ const loader = createVariantLoader(media) // returns undefined when no variants
364
+
365
+ <NextImage loader={loader} src={media.url} sizes="100vw" ... />
366
+ ```
367
+
368
+ **How the hybrid loader works:**
369
+ 1. Finds the smallest Payload size variant with width >= requested width
370
+ 2. If found → serves the pre-generated variant URL directly (bypasses `/_next/image`)
371
+ 3. If no variant is large enough → uses the largest variant if it covers >= 80% of requested width
372
+ 4. If no close match at all → falls back to `/_next/image` re-optimization
373
+
374
+ ### `getDefaultSizes()` — Smart Sizes Helper
375
+
376
+ Returns a sensible default `sizes` attribute for fill-mode images:
377
+
378
+ ```tsx
379
+ import { getDefaultSizes } from '@inoo-ch/payload-image-optimizer/client'
380
+
381
+ const sizes = getDefaultSizes(true) // '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
382
+ const sizes = getDefaultSizes(false) // undefined (let next/image use 1x/2x descriptors)
383
+ ```
384
+
385
+ ### `FadeImage` Component
386
+
387
+ Standalone Next.js `<Image>` wrapper with fade-in transition for use with `getImageOptimizerProps()`. Use this when you have a custom image component and want the fade effect without `ImageBox`.
388
+
389
+ ```tsx
390
+ import { FadeImage, getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
391
+
392
+ const optimizerProps = getImageOptimizerProps(resource)
393
+
394
+ <FadeImage
395
+ src={resource.url}
396
+ alt=""
397
+ width={800}
398
+ height={600}
399
+ optimizerProps={optimizerProps}
400
+ />
401
+ ```
402
+
403
+ **Props:** Extends all Next.js `ImageProps` (except `placeholder`, `blurDataURL`, `onLoad`), plus:
404
+
405
+ | Prop | Type | Default | Description |
406
+ |------|------|---------|-------------|
407
+ | `optimizerProps` | `ImageOptimizerProps` | — | Props returned by `getImageOptimizerProps()` |
408
+ | `fadeDuration` | `number` | `500` | Duration of the fade animation in milliseconds |
409
+
410
+ ### Client Utility Decision Guide
411
+
412
+ | Scenario | Use | Why |
413
+ |----------|-----|-----|
414
+ | **New project, fresh components** | `ImageBox` | Zero-config, handles everything |
415
+ | **Existing project with Payload website template** | `getOptimizedImageProps()` | 3-line change to existing `ImageMedia` |
416
+ | **Custom component, want blur + focal + variants** | `getOptimizedImageProps()` | Single spread, all features |
417
+ | **Custom component, only want blur + focal** | `getImageOptimizerProps()` | Lighter, no loader |
418
+ | **Fully custom loader logic** | `createVariantLoader()` | Granular control |
419
+ | **Custom component with fade animation** | `FadeImage` + `getImageOptimizerProps()` | Fade without ImageBox |
420
+
311
421
  ## Server-Side Utilities
312
422
 
313
423
  Import from `@inoo-ch/payload-image-optimizer`:
@@ -415,15 +525,46 @@ import type {
415
525
  CollectionOptimizerConfig,
416
526
  FormatQuality,
417
527
  ImageFormat, // 'webp' | 'avif'
528
+ MediaResource, // type for media documents passed to client utilities
529
+ MediaSizeVariant, // type for individual size variants in media.sizes
418
530
  } from '@inoo-ch/payload-image-optimizer'
419
531
 
420
532
  import type {
421
533
  ImageBoxProps,
422
534
  FadeImageProps,
423
535
  ImageOptimizerProps, // return type of getImageOptimizerProps
536
+ OptimizedImageProps, // return type of getOptimizedImageProps
424
537
  } from '@inoo-ch/payload-image-optimizer/client'
425
538
  ```
426
539
 
540
+ ### `MediaResource` Type
541
+
542
+ The `MediaResource` type represents a Payload media document as consumed by client utilities. It accepts the full Payload media response including the `sizes` field (generated by Payload when `imageSizes` is configured on the collection).
543
+
544
+ ```ts
545
+ type MediaResource = {
546
+ url?: string | null
547
+ alt?: string | null
548
+ width?: number | null
549
+ height?: number | null
550
+ filename?: string | null
551
+ focalX?: number | null
552
+ focalY?: number | null
553
+ imageOptimizer?: { thumbHash?: string | null } | null
554
+ updatedAt?: string
555
+ sizes?: Record<string, {
556
+ url?: string | null
557
+ width?: number | null
558
+ height?: number | null
559
+ mimeType?: string | null
560
+ filesize?: number | null
561
+ filename?: string | null
562
+ } | undefined>
563
+ }
564
+ ```
565
+
566
+ The type is intentionally loose — it structurally matches any Payload media document whether or not the plugin is installed. You can pass your generated Payload types directly without casting.
567
+
427
568
  ## Context Flags
428
569
 
429
570
  The plugin uses `req.context` flags to control processing:
package/README.md CHANGED
@@ -17,7 +17,9 @@ Built and maintained by [inoo.ch](https://inoo.ch) — a Swiss digital agency cr
17
17
  - **Bulk regeneration** — Re-process existing images from the admin UI with progress tracking
18
18
  - **Per-collection config** — Override formats, quality, and dimensions per collection
19
19
  - **Admin UI** — Status badges, file size savings, and blur previews in the sidebar
20
- - **ImageBox component** — Drop-in Next.js `<Image>` wrapper with automatic ThumbHash blur and smooth fade-in
20
+ - **ImageBox component** — Drop-in Next.js `<Image>` wrapper with ThumbHash blur, fade-in, responsive variant loading, and smart `sizes` defaults
21
+ - **Responsive variant loader** — Serves pre-generated Payload size variants directly, bypassing `/_next/image` re-optimization
22
+ - **Template-friendly** — `getOptimizedImageProps()` integrates with the Payload website template in 3 lines
21
23
  - **FadeImage component** — Standalone fade-in image for custom setups using `getImageOptimizerProps()`
22
24
 
23
25
  ## Requirements
@@ -86,7 +88,7 @@ imageOptimizer({
86
88
  maxDimensions: { width: 2560, height: 2560 },
87
89
  generateThumbHash: true,
88
90
  stripMetadata: true,
89
- clientOptimization: false,
91
+ clientOptimization: true,
90
92
  disabled: false,
91
93
  })
92
94
  ```
@@ -100,7 +102,7 @@ imageOptimizer({
100
102
  | `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions. Images are resized to fit within these bounds. |
101
103
  | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholders for instant image previews. |
102
104
  | `stripMetadata` | `boolean` | `true` | Remove EXIF and other metadata from images. |
103
- | `clientOptimization` | `boolean` | `false` | Pre-resize images in the browser before upload using Canvas API. Reduces upload size by up to 90% for large images. |
105
+ | `clientOptimization` | `boolean` | `true` | Pre-resize images in the browser before upload using Canvas API. Reduces upload size by up to 90% for large images. |
104
106
  | `disabled` | `boolean` | `false` | Disable optimization while keeping schema fields intact. |
105
107
 
106
108
  ### Per-Collection Overrides
@@ -218,32 +220,63 @@ The plugin adds an **Optimization Status** panel to the document sidebar showing
218
220
 
219
221
  A **Regenerate Images** button appears in collection list views, allowing you to bulk re-process existing images with a real-time progress bar.
220
222
 
221
- ## ImageBox Component
223
+ ## Displaying Images
224
+
225
+ ### Option 1: `ImageBox` (New Projects)
222
226
 
223
- The plugin exports an `ImageBox` component — a Next.js `<Image>` wrapper that automatically applies ThumbHash blur placeholders with a smooth blur-to-sharp fade transition:
227
+ Drop-in Next.js `<Image>` wrapper the easiest way to display images with best practices:
224
228
 
225
229
  ```tsx
226
230
  import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
227
231
 
228
- // Pass a Payload media document directly
229
- <ImageBox media={doc.heroImage} alt="Hero" />
232
+ // Hero image fill mode with priority
233
+ <ImageBox media={doc.heroImage} alt="Hero" fill priority />
234
+
235
+ // Card grid — explicit sizes hint
236
+ <ImageBox media={doc.image} alt="Card" fill sizes="(max-width: 768px) 100vw, 33vw" />
237
+
238
+ // Fixed dimensions
239
+ <ImageBox media={doc.avatar} alt="Avatar" width={64} height={64} fade={false} />
240
+ ```
241
+
242
+ **What it does automatically:**
243
+ - Per-image ThumbHash blur placeholder
244
+ - Smooth blur-to-sharp fade transition
245
+ - Focal point positioning from `focalX`/`focalY`
246
+ - Responsive variant loader — serves pre-generated Payload size variants directly instead of `/_next/image` re-optimization (when `imageSizes` is configured on the collection)
247
+ - Smart `sizes` default for fill mode — `(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw` instead of the browser's `100vw` assumption
248
+ - Cache busting via `updatedAt`
230
249
 
231
- // Or use a plain URL string
232
- <ImageBox media="/images/photo.jpg" alt="Photo" width={800} height={600} />
250
+ ### Option 2: `getOptimizedImageProps()` (Existing Projects / Payload Website Template)
233
251
 
234
- // Disable fade animation
235
- <ImageBox media={doc.image} alt="Photo" fade={false} />
252
+ If you're using the [Payload website template](https://github.com/payloadcms/payload/tree/main/templates/website) or have an existing `<NextImage>` component, add 3 lines:
236
253
 
237
- // Custom fade duration (default: 500ms)
238
- <ImageBox media={doc.image} alt="Photo" fadeDuration={300} />
254
+ ```tsx
255
+ import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
256
+
257
+ const optimizedProps = getOptimizedImageProps(resource)
258
+
259
+ <NextImage
260
+ {...optimizedProps} // ThumbHash blur, focal point, variant loader
261
+ src={src}
262
+ alt={alt}
263
+ fill={fill}
264
+ sizes={sizes}
265
+ quality={80}
266
+ />
239
267
  ```
240
268
 
241
- **Features:**
242
- - Automatic ThumbHash `blurDataURL` from the media document
243
- - Smooth blur-to-sharp fade transition on load (enabled by default)
244
- - Respects Payload focal point (`focalX` / `focalY`) for `objectPosition`
245
- - Lazy loading by default, with `priority` prop for above-the-fold images
246
- - Cache busting via `updatedAt` timestamp
269
+ This replaces the template's hardcoded blur placeholder with per-image ThumbHash, adds focal point support, and enables responsive variant loading.
270
+
271
+ ### Responsive Variant Loading
272
+
273
+ When your collection has `imageSizes` configured (e.g., `thumbnail: 300`, `medium: 900`, `large: 1400`), both `ImageBox` and `getOptimizedImageProps()` automatically create a hybrid `next/image` loader that:
274
+
275
+ 1. Picks the smallest pre-generated variant >= the requested width
276
+ 2. Serves it directly from your storage (bypasses `/_next/image` — no double optimization)
277
+ 3. Falls back to `/_next/image` when no close variant match exists
278
+
279
+ This means images uploaded to collections with `imageSizes` get responsive loading for free — no extra config needed.
247
280
 
248
281
  ## Document Schema
249
282
 
@@ -310,8 +343,9 @@ Copy-paste this instruction to your AI coding agent to have it autonomously inte
310
343
  >
311
344
  > 1. Which upload collections should be optimized and with what settings
312
345
  > 2. Whether to use `replaceOriginal` or keep originals alongside variants
313
- > 3. Where to add `<ImageBox>`, `<FadeImage>`, or `getImageOptimizerProps()` in the frontend for ThumbHash blur placeholders with smooth fade-in and focal point support
314
- > 4. Whether any existing image rendering code should use the optimized variants
346
+ > 3. For **new components**: use `<ImageBox>` it handles ThumbHash blur, fade-in, focal point, responsive variant loading, and smart `sizes` defaults automatically
347
+ > 4. For **existing components** (especially the Payload website template's `ImageMedia`): use `getOptimizedImageProps(resource)` a single spread that adds ThumbHash, focal point, and variant loader to any `<NextImage>`
348
+ > 5. If collections have `imageSizes` configured, the variant loader will automatically serve pre-generated size variants directly instead of going through `/_next/image` re-optimization
315
349
  >
316
350
  > Use the zero-config default (`collections: { <slug>: true }`) unless the project has specific requirements that call for custom settings.
317
351
 
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import React, { useState } from 'react';
3
+ import React, { useMemo, useState } from 'react';
4
4
  import NextImage from 'next/image';
5
5
  import { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
6
+ import { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js';
6
7
  export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, loading: loadingFromProps, style: styleFromProps, fade = true, fadeDuration = 500, ...props })=>{
7
8
  const [loaded, setLoaded] = useState(false);
8
9
  const loading = priority ? undefined : loadingFromProps ?? 'lazy';
@@ -17,7 +18,7 @@ export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, load
17
18
  alt: altFromProps || '',
18
19
  quality: 80,
19
20
  fill: fill,
20
- sizes: sizes,
21
+ sizes: sizes ?? getDefaultSizes(fill),
21
22
  style: {
22
23
  objectFit: 'cover',
23
24
  objectPosition: 'center',
@@ -34,6 +35,9 @@ export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, load
34
35
  const alt = altFromProps || media.alt || media.filename || '';
35
36
  const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : '';
36
37
  const optimizerProps = getImageOptimizerProps(media);
38
+ const variantLoader = useMemo(()=>createVariantLoader(media), [
39
+ media
40
+ ]);
37
41
  return /*#__PURE__*/ _jsx(NextImage, {
38
42
  ...props,
39
43
  src: src,
@@ -42,7 +46,8 @@ export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, load
42
46
  fill: fill,
43
47
  width: !fill ? width : undefined,
44
48
  height: !fill ? height : undefined,
45
- sizes: sizes,
49
+ sizes: sizes ?? getDefaultSizes(fill),
50
+ loader: variantLoader,
46
51
  style: {
47
52
  objectFit: 'cover',
48
53
  ...optimizerProps.style,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/ImageBox.tsx"],"sourcesContent":["'use client'\n\nimport React, { useState } 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 /** Enable smooth blur-to-sharp fade transition on load. Defaults to `true`. */\n fade?: boolean\n /** Duration of the fade animation in milliseconds. Defaults to `500`. */\n fadeDuration?: number\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 fade = true,\n fadeDuration = 500,\n ...props\n}) => {\n const [loaded, setLoaded] = useState(false)\n const loading = priority ? undefined : (loadingFromProps ?? 'lazy')\n\n const fadeStyle = fade\n ? {\n filter: loaded ? 'blur(0px)' : 'blur(20px)',\n transition: loaded ? `filter ${fadeDuration}ms ease-in-out` : undefined,\n }\n : undefined\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', ...fadeStyle, ...styleFromProps }}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\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, ...fadeStyle, ...styleFromProps }}\n placeholder={optimizerProps.placeholder}\n blurDataURL={optimizerProps.blurDataURL}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\n />\n )\n}\n"],"names":["React","useState","NextImage","getImageOptimizerProps","ImageBox","media","alt","altFromProps","fill","sizes","priority","loading","loadingFromProps","style","styleFromProps","fade","fadeDuration","props","loaded","setLoaded","undefined","fadeStyle","filter","transition","src","quality","objectFit","objectPosition","onLoad","width","height","filename","url","updatedAt","optimizerProps","placeholder","blurDataURL"],"mappings":"AAAA;;AAEA,OAAOA,SAASC,QAAQ,QAAQ,QAAO;AACvC,OAAOC,eAAoC,aAAY;AAEvD,SAASC,sBAAsB,QAAQ,yCAAwC;AAW/E,OAAO,MAAMC,WAAoC,CAAC,EAChDC,KAAK,EACLC,KAAKC,YAAY,EACjBC,IAAI,EACJC,KAAK,EACLC,QAAQ,EACRC,SAASC,gBAAgB,EACzBC,OAAOC,cAAc,EACrBC,OAAO,IAAI,EACXC,eAAe,GAAG,EAClB,GAAGC,OACJ;IACC,MAAM,CAACC,QAAQC,UAAU,GAAGlB,SAAS;IACrC,MAAMU,UAAUD,WAAWU,YAAaR,oBAAoB;IAE5D,MAAMS,YAAYN,OACd;QACEO,QAAQJ,SAAS,cAAc;QAC/BK,YAAYL,SAAS,CAAC,OAAO,EAAEF,aAAa,cAAc,CAAC,GAAGI;IAChE,IACAA;IAEJ,IAAI,OAAOf,UAAU,UAAU;QAC7B,qBACE,KAACH;YACE,GAAGe,KAAK;YACTO,KAAKnB;YACLC,KAAKC,gBAAgB;YACrBkB,SAAS;YACTjB,MAAMA;YACNC,OAAOA;YACPI,OAAO;gBAAEa,WAAW;gBAASC,gBAAgB;gBAAU,GAAGN,SAAS;gBAAE,GAAGP,cAAc;YAAC;YACvFJ,UAAUA;YACVC,SAASA;YACTiB,QAAQb,OAAO,IAAMI,UAAU,QAAQC;;IAG7C;IAEA,MAAMS,QAAQxB,MAAMwB,KAAK,IAAIT;IAC7B,MAAMU,SAASzB,MAAMyB,MAAM,IAAIV;IAC/B,MAAMd,MAAMC,gBAAgB,AAACF,MAAcC,GAAG,IAAID,MAAM0B,QAAQ,IAAI;IACpE,MAAMP,MAAMnB,MAAM2B,GAAG,GAAG,GAAG3B,MAAM2B,GAAG,GAAG3B,MAAM4B,SAAS,GAAG,CAAC,CAAC,EAAE5B,MAAM4B,SAAS,EAAE,GAAG,IAAI,GAAG;IAExF,MAAMC,iBAAiB/B,uBAAuBE;IAE9C,qBACE,KAACH;QACE,GAAGe,KAAK;QACTO,KAAKA;QACLlB,KAAKA;QACLmB,SAAS;QACTjB,MAAMA;QACNqB,OAAO,CAACrB,OAAOqB,QAAQT;QACvBU,QAAQ,CAACtB,OAAOsB,SAASV;QACzBX,OAAOA;QACPI,OAAO;YAAEa,WAAW;YAAS,GAAGQ,eAAerB,KAAK;YAAE,GAAGQ,SAAS;YAAE,GAAGP,cAAc;QAAC;QACtFqB,aAAaD,eAAeC,WAAW;QACvCC,aAAaF,eAAeE,WAAW;QACvC1B,UAAUA;QACVC,SAASA;QACTiB,QAAQb,OAAO,IAAMI,UAAU,QAAQC;;AAG7C,EAAC"}
1
+ {"version":3,"sources":["../../src/components/ImageBox.tsx"],"sourcesContent":["'use client'\n\nimport React, { useMemo, useState } from 'react'\nimport NextImage, { type ImageProps } from 'next/image'\nimport type { MediaResource } from '../types.js'\nimport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nimport { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js'\n\nexport interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {\n media: MediaResource | string\n alt?: string\n /** Enable smooth blur-to-sharp fade transition on load. Defaults to `true`. */\n fade?: boolean\n /** Duration of the fade animation in milliseconds. Defaults to `500`. */\n fadeDuration?: number\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 fade = true,\n fadeDuration = 500,\n ...props\n}) => {\n const [loaded, setLoaded] = useState(false)\n const loading = priority ? undefined : (loadingFromProps ?? 'lazy')\n\n const fadeStyle = fade\n ? {\n filter: loaded ? 'blur(0px)' : 'blur(20px)',\n transition: loaded ? `filter ${fadeDuration}ms ease-in-out` : undefined,\n }\n : undefined\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 ?? getDefaultSizes(fill)}\n style={{ objectFit: 'cover', objectPosition: 'center', ...fadeStyle, ...styleFromProps }}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\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 const variantLoader = useMemo(() => createVariantLoader(media), [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 ?? getDefaultSizes(fill)}\n loader={variantLoader}\n style={{ objectFit: 'cover', ...optimizerProps.style, ...fadeStyle, ...styleFromProps }}\n placeholder={optimizerProps.placeholder}\n blurDataURL={optimizerProps.blurDataURL}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\n />\n )\n}\n"],"names":["React","useMemo","useState","NextImage","getImageOptimizerProps","createVariantLoader","getDefaultSizes","ImageBox","media","alt","altFromProps","fill","sizes","priority","loading","loadingFromProps","style","styleFromProps","fade","fadeDuration","props","loaded","setLoaded","undefined","fadeStyle","filter","transition","src","quality","objectFit","objectPosition","onLoad","width","height","filename","url","updatedAt","optimizerProps","variantLoader","loader","placeholder","blurDataURL"],"mappings":"AAAA;;AAEA,OAAOA,SAASC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AAChD,OAAOC,eAAoC,aAAY;AAEvD,SAASC,sBAAsB,QAAQ,yCAAwC;AAC/E,SAASC,mBAAmB,EAAEC,eAAe,QAAQ,kCAAiC;AAWtF,OAAO,MAAMC,WAAoC,CAAC,EAChDC,KAAK,EACLC,KAAKC,YAAY,EACjBC,IAAI,EACJC,KAAK,EACLC,QAAQ,EACRC,SAASC,gBAAgB,EACzBC,OAAOC,cAAc,EACrBC,OAAO,IAAI,EACXC,eAAe,GAAG,EAClB,GAAGC,OACJ;IACC,MAAM,CAACC,QAAQC,UAAU,GAAGpB,SAAS;IACrC,MAAMY,UAAUD,WAAWU,YAAaR,oBAAoB;IAE5D,MAAMS,YAAYN,OACd;QACEO,QAAQJ,SAAS,cAAc;QAC/BK,YAAYL,SAAS,CAAC,OAAO,EAAEF,aAAa,cAAc,CAAC,GAAGI;IAChE,IACAA;IAEJ,IAAI,OAAOf,UAAU,UAAU;QAC7B,qBACE,KAACL;YACE,GAAGiB,KAAK;YACTO,KAAKnB;YACLC,KAAKC,gBAAgB;YACrBkB,SAAS;YACTjB,MAAMA;YACNC,OAAOA,SAASN,gBAAgBK;YAChCK,OAAO;gBAAEa,WAAW;gBAASC,gBAAgB;gBAAU,GAAGN,SAAS;gBAAE,GAAGP,cAAc;YAAC;YACvFJ,UAAUA;YACVC,SAASA;YACTiB,QAAQb,OAAO,IAAMI,UAAU,QAAQC;;IAG7C;IAEA,MAAMS,QAAQxB,MAAMwB,KAAK,IAAIT;IAC7B,MAAMU,SAASzB,MAAMyB,MAAM,IAAIV;IAC/B,MAAMd,MAAMC,gBAAgB,AAACF,MAAcC,GAAG,IAAID,MAAM0B,QAAQ,IAAI;IACpE,MAAMP,MAAMnB,MAAM2B,GAAG,GAAG,GAAG3B,MAAM2B,GAAG,GAAG3B,MAAM4B,SAAS,GAAG,CAAC,CAAC,EAAE5B,MAAM4B,SAAS,EAAE,GAAG,IAAI,GAAG;IAExF,MAAMC,iBAAiBjC,uBAAuBI;IAC9C,MAAM8B,gBAAgBrC,QAAQ,IAAMI,oBAAoBG,QAAQ;QAACA;KAAM;IAEvE,qBACE,KAACL;QACE,GAAGiB,KAAK;QACTO,KAAKA;QACLlB,KAAKA;QACLmB,SAAS;QACTjB,MAAMA;QACNqB,OAAO,CAACrB,OAAOqB,QAAQT;QACvBU,QAAQ,CAACtB,OAAOsB,SAASV;QACzBX,OAAOA,SAASN,gBAAgBK;QAChC4B,QAAQD;QACRtB,OAAO;YAAEa,WAAW;YAAS,GAAGQ,eAAerB,KAAK;YAAE,GAAGQ,SAAS;YAAE,GAAGP,cAAc;QAAC;QACtFuB,aAAaH,eAAeG,WAAW;QACvCC,aAAaJ,eAAeI,WAAW;QACvC5B,UAAUA;QACVC,SAASA;QACTiB,QAAQb,OAAO,IAAMI,UAAU,QAAQC;;AAG7C,EAAC"}
package/dist/defaults.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const resolveConfig = (config)=>({
2
- clientOptimization: config.clientOptimization ?? false,
2
+ clientOptimization: config.clientOptimization ?? true,
3
3
  collections: config.collections,
4
4
  disabled: config.disabled ?? false,
5
5
  formats: config.formats ?? [
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nimport type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'\n\nexport const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({\n clientOptimization: config.clientOptimization ?? false,\n collections: config.collections,\n disabled: config.disabled ?? false,\n formats: config.formats ?? [\n { format: 'webp', quality: 80 },\n ],\n generateThumbHash: config.generateThumbHash ?? true,\n maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },\n replaceOriginal: config.replaceOriginal ?? true,\n stripMetadata: config.stripMetadata ?? true,\n})\n\nexport const resolveCollectionConfig = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): ResolvedCollectionOptimizerConfig => {\n const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]\n\n if (!collectionValue || collectionValue === true) {\n return {\n formats: resolvedConfig.formats,\n maxDimensions: resolvedConfig.maxDimensions,\n replaceOriginal: resolvedConfig.replaceOriginal,\n }\n }\n\n return {\n formats: collectionValue.formats ?? resolvedConfig.formats,\n maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,\n replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal,\n }\n}\n"],"names":["resolveConfig","config","clientOptimization","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,oBAAoBD,OAAOC,kBAAkB,IAAI;QACjDC,aAAaF,OAAOE,WAAW;QAC/BC,UAAUH,OAAOG,QAAQ,IAAI;QAC7BC,SAASJ,OAAOI,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBP,OAAOO,iBAAiB,IAAI;QAC/CC,eAAeR,OAAOQ,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBX,OAAOW,eAAe,IAAI;QAC3CC,eAAeZ,OAAOY,aAAa,IAAI;IACzC,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeZ,WAAW,CAACa,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLZ,SAASU,eAAeV,OAAO;YAC/BI,eAAeM,eAAeN,aAAa;YAC3CG,iBAAiBG,eAAeH,eAAe;QACjD;IACF;IAEA,OAAO;QACLP,SAASY,gBAAgBZ,OAAO,IAAIU,eAAeV,OAAO;QAC1DI,eAAeQ,gBAAgBR,aAAa,IAAIM,eAAeN,aAAa;QAC5EG,iBAAiBK,gBAAgBL,eAAe,IAAIG,eAAeH,eAAe;IACpF;AACF,EAAC"}
1
+ {"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nimport type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'\n\nexport const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({\n clientOptimization: config.clientOptimization ?? true,\n collections: config.collections,\n disabled: config.disabled ?? false,\n formats: config.formats ?? [\n { format: 'webp', quality: 80 },\n ],\n generateThumbHash: config.generateThumbHash ?? true,\n maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },\n replaceOriginal: config.replaceOriginal ?? true,\n stripMetadata: config.stripMetadata ?? true,\n})\n\nexport const resolveCollectionConfig = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): ResolvedCollectionOptimizerConfig => {\n const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]\n\n if (!collectionValue || collectionValue === true) {\n return {\n formats: resolvedConfig.formats,\n maxDimensions: resolvedConfig.maxDimensions,\n replaceOriginal: resolvedConfig.replaceOriginal,\n }\n }\n\n return {\n formats: collectionValue.formats ?? resolvedConfig.formats,\n maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,\n replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal,\n }\n}\n"],"names":["resolveConfig","config","clientOptimization","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,oBAAoBD,OAAOC,kBAAkB,IAAI;QACjDC,aAAaF,OAAOE,WAAW;QAC/BC,UAAUH,OAAOG,QAAQ,IAAI;QAC7BC,SAASJ,OAAOI,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBP,OAAOO,iBAAiB,IAAI;QAC/CC,eAAeR,OAAOQ,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBX,OAAOW,eAAe,IAAI;QAC3CC,eAAeZ,OAAOY,aAAa,IAAI;IACzC,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeZ,WAAW,CAACa,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLZ,SAASU,eAAeV,OAAO;YAC/BI,eAAeM,eAAeN,aAAa;YAC3CG,iBAAiBG,eAAeH,eAAe;QACjD;IACF;IAEA,OAAO;QACLP,SAASY,gBAAgBZ,OAAO,IAAIU,eAAeV,OAAO;QAC1DI,eAAeQ,gBAAgBR,aAAa,IAAIM,eAAeN,aAAa;QAC5EG,iBAAiBK,gBAAgBL,eAAe,IAAIG,eAAeH,eAAe;IACpF;AACF,EAAC"}
@@ -5,5 +5,8 @@ export { FadeImage } from '../components/FadeImage.js';
5
5
  export type { FadeImageProps } from '../components/FadeImage.js';
6
6
  export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
7
7
  export type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
8
+ export { getOptimizedImageProps } from '../utilities/getOptimizedImageProps.js';
9
+ export type { OptimizedImageProps } from '../utilities/getOptimizedImageProps.js';
10
+ export { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js';
8
11
  export { RegenerationButton } from '../components/RegenerationButton.js';
9
12
  export { UploadOptimizer } from '../components/UploadOptimizer.js';
@@ -2,6 +2,8 @@ export { OptimizationStatus } from '../components/OptimizationStatus.js';
2
2
  export { ImageBox } from '../components/ImageBox.js';
3
3
  export { FadeImage } from '../components/FadeImage.js';
4
4
  export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
5
+ export { getOptimizedImageProps } from '../utilities/getOptimizedImageProps.js';
6
+ export { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js';
5
7
  export { RegenerationButton } from '../components/RegenerationButton.js';
6
8
  export { UploadOptimizer } from '../components/UploadOptimizer.js';
7
9
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["export { OptimizationStatus } from '../components/OptimizationStatus.js'\nexport { ImageBox } from '../components/ImageBox.js'\nexport type { ImageBoxProps } from '../components/ImageBox.js'\nexport { FadeImage } from '../components/FadeImage.js'\nexport type { FadeImageProps } from '../components/FadeImage.js'\nexport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport { RegenerationButton } from '../components/RegenerationButton.js'\nexport { UploadOptimizer } from '../components/UploadOptimizer.js'\n"],"names":["OptimizationStatus","ImageBox","FadeImage","getImageOptimizerProps","RegenerationButton","UploadOptimizer"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,QAAQ,QAAQ,4BAA2B;AAEpD,SAASC,SAAS,QAAQ,6BAA4B;AAEtD,SAASC,sBAAsB,QAAQ,yCAAwC;AAE/E,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,eAAe,QAAQ,mCAAkC"}
1
+ {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["export { OptimizationStatus } from '../components/OptimizationStatus.js'\nexport { ImageBox } from '../components/ImageBox.js'\nexport type { ImageBoxProps } from '../components/ImageBox.js'\nexport { FadeImage } from '../components/FadeImage.js'\nexport type { FadeImageProps } from '../components/FadeImage.js'\nexport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport { getOptimizedImageProps } from '../utilities/getOptimizedImageProps.js'\nexport type { OptimizedImageProps } from '../utilities/getOptimizedImageProps.js'\nexport { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js'\nexport { RegenerationButton } from '../components/RegenerationButton.js'\nexport { UploadOptimizer } from '../components/UploadOptimizer.js'\n"],"names":["OptimizationStatus","ImageBox","FadeImage","getImageOptimizerProps","getOptimizedImageProps","createVariantLoader","getDefaultSizes","RegenerationButton","UploadOptimizer"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,QAAQ,QAAQ,4BAA2B;AAEpD,SAASC,SAAS,QAAQ,6BAA4B;AAEtD,SAASC,sBAAsB,QAAQ,yCAAwC;AAE/E,SAASC,sBAAsB,QAAQ,yCAAwC;AAE/E,SAASC,mBAAmB,EAAEC,eAAe,QAAQ,kCAAiC;AACtF,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,eAAe,QAAQ,mCAAkC"}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Config } from 'payload';
2
2
  import type { ImageOptimizerConfig } from './types.js';
3
- export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, FieldsOverride } from './types.js';
3
+ export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, MediaSizeVariant, FieldsOverride } from './types.js';
4
4
  export { defaultImageOptimizerFields } from './fields/imageOptimizerField.js';
5
5
  export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js';
6
6
  /**
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Config } from 'payload'\nimport { deepMergeSimple } from 'payload/shared'\n\nimport type { ImageOptimizerConfig } from './types.js'\nimport { resolveConfig } from './defaults.js'\nimport { translations } from './translations/index.js'\nimport { getImageOptimizerField } from './fields/imageOptimizerField.js'\nimport { createBeforeChangeHook } from './hooks/beforeChange.js'\nimport { createAfterChangeHook } from './hooks/afterChange.js'\nimport { createConvertFormatsHandler } from './tasks/convertFormats.js'\nimport { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js'\nimport { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js'\n\nexport type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, FieldsOverride } from './types.js'\nexport { defaultImageOptimizerFields } from './fields/imageOptimizerField.js'\n\nexport { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'\n\n/**\n * Recommended maxDuration for the Payload API route on Vercel.\n * Re-export this in your route file:\n *\n * export { maxDuration } from '@inoo-ch/payload-image-optimizer'\n */\nexport const maxDuration = 60\n\nexport const imageOptimizer =\n (pluginOptions: ImageOptimizerConfig) =>\n (config: Config): Config => {\n const resolvedConfig = resolveConfig(pluginOptions)\n const targetSlugs = Object.keys(resolvedConfig.collections)\n\n // Inject fields (and hooks when enabled) into targeted upload collections\n const collections = (config.collections || []).map((collection) => {\n if (!targetSlugs.includes(collection.slug)) {\n return collection\n }\n\n // Always inject fields for schema consistency (even when disabled)\n const fields = [...collection.fields, getImageOptimizerField(pluginOptions.fieldsOverride)]\n\n if (resolvedConfig.disabled) {\n return { ...collection, fields }\n }\n\n return {\n ...collection,\n fields,\n hooks: {\n ...collection.hooks,\n beforeChange: [\n ...(collection.hooks?.beforeChange || []),\n createBeforeChangeHook(resolvedConfig, collection.slug),\n ],\n afterChange: [\n ...(collection.hooks?.afterChange || []),\n createAfterChangeHook(resolvedConfig, collection.slug),\n ],\n },\n admin: {\n ...collection.admin,\n components: {\n ...collection.admin?.components,\n ...(resolvedConfig.clientOptimization && !collection.admin?.components?.edit?.Upload\n ? {\n edit: {\n ...collection.admin?.components?.edit,\n Upload: '@inoo-ch/payload-image-optimizer/client#UploadOptimizer',\n },\n }\n : {}),\n beforeListTable: [\n ...(collection.admin?.components?.beforeListTable || []),\n '@inoo-ch/payload-image-optimizer/client#RegenerationButton',\n ],\n },\n },\n }\n })\n\n const i18n = {\n ...config.i18n,\n translations: deepMergeSimple(translations, config.i18n?.translations ?? {}),\n }\n\n // If disabled, return with fields injected but no tasks/endpoints\n if (resolvedConfig.disabled) {\n return { ...config, collections, i18n }\n }\n\n return {\n ...config,\n collections,\n i18n,\n jobs: {\n ...config.jobs,\n tasks: [\n ...(config.jobs?.tasks || []),\n {\n slug: 'imageOptimizer_convertFormats',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'variantsGenerated', type: 'number' },\n ],\n retries: 2,\n handler: createConvertFormatsHandler(resolvedConfig),\n } as any,\n {\n slug: 'imageOptimizer_regenerateDocument',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'status', type: 'text' },\n { name: 'reason', type: 'text' },\n ],\n retries: 2,\n handler: createRegenerateDocumentHandler(resolvedConfig),\n } as any,\n ],\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n path: '/image-optimizer/regenerate',\n method: 'post',\n handler: createRegenerateHandler(resolvedConfig),\n },\n {\n path: '/image-optimizer/regenerate',\n method: 'get',\n handler: createRegenerateStatusHandler(resolvedConfig),\n },\n ],\n }\n }\n"],"names":["deepMergeSimple","resolveConfig","translations","getImageOptimizerField","createBeforeChangeHook","createAfterChangeHook","createConvertFormatsHandler","createRegenerateDocumentHandler","createRegenerateHandler","createRegenerateStatusHandler","defaultImageOptimizerFields","encodeImageToThumbHash","decodeThumbHashToDataURL","maxDuration","imageOptimizer","pluginOptions","config","resolvedConfig","targetSlugs","Object","keys","collections","map","collection","includes","slug","fields","fieldsOverride","disabled","hooks","beforeChange","afterChange","admin","components","clientOptimization","edit","Upload","beforeListTable","i18n","jobs","tasks","inputSchema","name","type","required","outputSchema","retries","handler","endpoints","path","method"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Config } from 'payload'\nimport { deepMergeSimple } from 'payload/shared'\n\nimport type { ImageOptimizerConfig } from './types.js'\nimport { resolveConfig } from './defaults.js'\nimport { translations } from './translations/index.js'\nimport { getImageOptimizerField } from './fields/imageOptimizerField.js'\nimport { createBeforeChangeHook } from './hooks/beforeChange.js'\nimport { createAfterChangeHook } from './hooks/afterChange.js'\nimport { createConvertFormatsHandler } from './tasks/convertFormats.js'\nimport { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js'\nimport { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js'\n\nexport type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, MediaSizeVariant, FieldsOverride } from './types.js'\nexport { defaultImageOptimizerFields } from './fields/imageOptimizerField.js'\n\nexport { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'\n\n/**\n * Recommended maxDuration for the Payload API route on Vercel.\n * Re-export this in your route file:\n *\n * export { maxDuration } from '@inoo-ch/payload-image-optimizer'\n */\nexport const maxDuration = 60\n\nexport const imageOptimizer =\n (pluginOptions: ImageOptimizerConfig) =>\n (config: Config): Config => {\n const resolvedConfig = resolveConfig(pluginOptions)\n const targetSlugs = Object.keys(resolvedConfig.collections)\n\n // Inject fields (and hooks when enabled) into targeted upload collections\n const collections = (config.collections || []).map((collection) => {\n if (!targetSlugs.includes(collection.slug)) {\n return collection\n }\n\n // Always inject fields for schema consistency (even when disabled)\n const fields = [...collection.fields, getImageOptimizerField(pluginOptions.fieldsOverride)]\n\n if (resolvedConfig.disabled) {\n return { ...collection, fields }\n }\n\n return {\n ...collection,\n fields,\n hooks: {\n ...collection.hooks,\n beforeChange: [\n ...(collection.hooks?.beforeChange || []),\n createBeforeChangeHook(resolvedConfig, collection.slug),\n ],\n afterChange: [\n ...(collection.hooks?.afterChange || []),\n createAfterChangeHook(resolvedConfig, collection.slug),\n ],\n },\n admin: {\n ...collection.admin,\n components: {\n ...collection.admin?.components,\n ...(resolvedConfig.clientOptimization && !collection.admin?.components?.edit?.Upload\n ? {\n edit: {\n ...collection.admin?.components?.edit,\n Upload: '@inoo-ch/payload-image-optimizer/client#UploadOptimizer',\n },\n }\n : {}),\n beforeListTable: [\n ...(collection.admin?.components?.beforeListTable || []),\n '@inoo-ch/payload-image-optimizer/client#RegenerationButton',\n ],\n },\n },\n }\n })\n\n const i18n = {\n ...config.i18n,\n translations: deepMergeSimple(translations, config.i18n?.translations ?? {}),\n }\n\n // If disabled, return with fields injected but no tasks/endpoints\n if (resolvedConfig.disabled) {\n return { ...config, collections, i18n }\n }\n\n return {\n ...config,\n collections,\n i18n,\n jobs: {\n ...config.jobs,\n tasks: [\n ...(config.jobs?.tasks || []),\n {\n slug: 'imageOptimizer_convertFormats',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'variantsGenerated', type: 'number' },\n ],\n retries: 2,\n handler: createConvertFormatsHandler(resolvedConfig),\n } as any,\n {\n slug: 'imageOptimizer_regenerateDocument',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'status', type: 'text' },\n { name: 'reason', type: 'text' },\n ],\n retries: 2,\n handler: createRegenerateDocumentHandler(resolvedConfig),\n } as any,\n ],\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n path: '/image-optimizer/regenerate',\n method: 'post',\n handler: createRegenerateHandler(resolvedConfig),\n },\n {\n path: '/image-optimizer/regenerate',\n method: 'get',\n handler: createRegenerateStatusHandler(resolvedConfig),\n },\n ],\n }\n }\n"],"names":["deepMergeSimple","resolveConfig","translations","getImageOptimizerField","createBeforeChangeHook","createAfterChangeHook","createConvertFormatsHandler","createRegenerateDocumentHandler","createRegenerateHandler","createRegenerateStatusHandler","defaultImageOptimizerFields","encodeImageToThumbHash","decodeThumbHashToDataURL","maxDuration","imageOptimizer","pluginOptions","config","resolvedConfig","targetSlugs","Object","keys","collections","map","collection","includes","slug","fields","fieldsOverride","disabled","hooks","beforeChange","afterChange","admin","components","clientOptimization","edit","Upload","beforeListTable","i18n","jobs","tasks","inputSchema","name","type","required","outputSchema","retries","handler","endpoints","path","method"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -46,6 +46,14 @@ export type ResolvedImageOptimizerConfig = Required<Pick<ImageOptimizerConfig, '
46
46
  export type ImageOptimizerData = {
47
47
  thumbHash?: string | null;
48
48
  };
49
+ export type MediaSizeVariant = {
50
+ url?: string | null;
51
+ width?: number | null;
52
+ height?: number | null;
53
+ mimeType?: string | null;
54
+ filesize?: number | null;
55
+ filename?: string | null;
56
+ };
49
57
  export type MediaResource = {
50
58
  url?: string | null;
51
59
  alt?: string | null;
@@ -56,4 +64,5 @@ export type MediaResource = {
56
64
  focalY?: number | null;
57
65
  imageOptimizer?: ImageOptimizerData | null;
58
66
  updatedAt?: string;
67
+ sizes?: Record<string, MediaSizeVariant | undefined>;
59
68
  };
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug, Field } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n}\n\nexport type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\nexport type ImageOptimizerConfig = {\n clientOptimization?: boolean\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n fieldsOverride?: FieldsOverride\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n stripMetadata?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n replaceOriginal: boolean\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n clientOptimization: boolean\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n replaceOriginal: boolean\n}\n\nexport type ImageOptimizerData = {\n thumbHash?: string | null\n}\n\nexport type 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"],"names":[],"mappings":"AAgDA,WAUC"}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug, Field } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n}\n\nexport type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\nexport type ImageOptimizerConfig = {\n clientOptimization?: boolean\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n fieldsOverride?: FieldsOverride\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n stripMetadata?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n replaceOriginal: boolean\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n clientOptimization: boolean\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n replaceOriginal: boolean\n}\n\nexport type ImageOptimizerData = {\n thumbHash?: string | null\n}\n\nexport type MediaSizeVariant = {\n url?: string | null\n width?: number | null\n height?: number | null\n mimeType?: string | null\n filesize?: number | null\n filename?: string | null\n}\n\nexport type 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 sizes?: Record<string, MediaSizeVariant | undefined>\n}\n"],"names":[],"mappings":"AAyDA,WAWC"}
@@ -0,0 +1,44 @@
1
+ import type { MediaResource } from '../types.js';
2
+ import { type ImageOptimizerProps } from './getImageOptimizerProps.js';
3
+ type ImageLoaderProps = {
4
+ src: string;
5
+ width: number;
6
+ quality?: number | undefined;
7
+ };
8
+ type ImageLoader = (props: ImageLoaderProps) => string;
9
+ export type OptimizedImageProps = ImageOptimizerProps & {
10
+ loader?: ImageLoader;
11
+ };
12
+ /**
13
+ * Returns all optimization props for a Next.js `<Image>` component in a single
14
+ * spread-friendly object: ThumbHash blur placeholder, focal-point positioning,
15
+ * and a variant-aware responsive loader.
16
+ *
17
+ * Designed as a drop-in enhancement for the Payload website template's `ImageMedia`:
18
+ *
19
+ * ```tsx
20
+ * // In your ImageMedia component — just add the import and spread:
21
+ * import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
22
+ *
23
+ * const optimizedProps = getOptimizedImageProps(resource)
24
+ *
25
+ * <NextImage
26
+ * {...optimizedProps}
27
+ * src={src}
28
+ * alt={alt}
29
+ * fill={fill}
30
+ * sizes={sizes}
31
+ * priority={priority}
32
+ * loading={loading}
33
+ * />
34
+ * ```
35
+ *
36
+ * What it returns:
37
+ * - `placeholder` / `blurDataURL` — per-image ThumbHash (replaces the template's hardcoded blur)
38
+ * - `style.objectPosition` — focal-point-based positioning
39
+ * - `loader` — hybrid loader that serves pre-generated Payload size variants directly,
40
+ * falling back to `/_next/image` when no close match exists (only present when
41
+ * `resource.sizes` has variants)
42
+ */
43
+ export declare function getOptimizedImageProps(resource: MediaResource | null | undefined): OptimizedImageProps;
44
+ export {};
@@ -0,0 +1,43 @@
1
+ import { getImageOptimizerProps } from './getImageOptimizerProps.js';
2
+ import { createVariantLoader } from './responsiveImage.js';
3
+ /**
4
+ * Returns all optimization props for a Next.js `<Image>` component in a single
5
+ * spread-friendly object: ThumbHash blur placeholder, focal-point positioning,
6
+ * and a variant-aware responsive loader.
7
+ *
8
+ * Designed as a drop-in enhancement for the Payload website template's `ImageMedia`:
9
+ *
10
+ * ```tsx
11
+ * // In your ImageMedia component — just add the import and spread:
12
+ * import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
13
+ *
14
+ * const optimizedProps = getOptimizedImageProps(resource)
15
+ *
16
+ * <NextImage
17
+ * {...optimizedProps}
18
+ * src={src}
19
+ * alt={alt}
20
+ * fill={fill}
21
+ * sizes={sizes}
22
+ * priority={priority}
23
+ * loading={loading}
24
+ * />
25
+ * ```
26
+ *
27
+ * What it returns:
28
+ * - `placeholder` / `blurDataURL` — per-image ThumbHash (replaces the template's hardcoded blur)
29
+ * - `style.objectPosition` — focal-point-based positioning
30
+ * - `loader` — hybrid loader that serves pre-generated Payload size variants directly,
31
+ * falling back to `/_next/image` when no close match exists (only present when
32
+ * `resource.sizes` has variants)
33
+ */ export function getOptimizedImageProps(resource) {
34
+ const base = getImageOptimizerProps(resource);
35
+ if (!resource) return base;
36
+ const loader = createVariantLoader(resource);
37
+ return loader ? {
38
+ ...base,
39
+ loader
40
+ } : base;
41
+ }
42
+
43
+ //# sourceMappingURL=getOptimizedImageProps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/getOptimizedImageProps.ts"],"sourcesContent":["import type { MediaResource } from '../types.js'\nimport { getImageOptimizerProps, type ImageOptimizerProps } from './getImageOptimizerProps.js'\nimport { createVariantLoader } from './responsiveImage.js'\n\ntype ImageLoaderProps = { src: string; width: number; quality?: number | undefined }\ntype ImageLoader = (props: ImageLoaderProps) => string\n\nexport type OptimizedImageProps = ImageOptimizerProps & {\n loader?: ImageLoader\n}\n\n/**\n * Returns all optimization props for a Next.js `<Image>` component in a single\n * spread-friendly object: ThumbHash blur placeholder, focal-point positioning,\n * and a variant-aware responsive loader.\n *\n * Designed as a drop-in enhancement for the Payload website template's `ImageMedia`:\n *\n * ```tsx\n * // In your ImageMedia component — just add the import and spread:\n * import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'\n *\n * const optimizedProps = getOptimizedImageProps(resource)\n *\n * <NextImage\n * {...optimizedProps}\n * src={src}\n * alt={alt}\n * fill={fill}\n * sizes={sizes}\n * priority={priority}\n * loading={loading}\n * />\n * ```\n *\n * What it returns:\n * - `placeholder` / `blurDataURL` — per-image ThumbHash (replaces the template's hardcoded blur)\n * - `style.objectPosition` — focal-point-based positioning\n * - `loader` — hybrid loader that serves pre-generated Payload size variants directly,\n * falling back to `/_next/image` when no close match exists (only present when\n * `resource.sizes` has variants)\n */\nexport function getOptimizedImageProps(\n resource: MediaResource | null | undefined,\n): OptimizedImageProps {\n const base = getImageOptimizerProps(resource)\n\n if (!resource) return base\n\n const loader = createVariantLoader(resource)\n\n return loader ? { ...base, loader } : base\n}\n"],"names":["getImageOptimizerProps","createVariantLoader","getOptimizedImageProps","resource","base","loader"],"mappings":"AACA,SAASA,sBAAsB,QAAkC,8BAA6B;AAC9F,SAASC,mBAAmB,QAAQ,uBAAsB;AAS1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,uBACdC,QAA0C;IAE1C,MAAMC,OAAOJ,uBAAuBG;IAEpC,IAAI,CAACA,UAAU,OAAOC;IAEtB,MAAMC,SAASJ,oBAAoBE;IAEnC,OAAOE,SAAS;QAAE,GAAGD,IAAI;QAAEC;IAAO,IAAID;AACxC"}
@@ -0,0 +1,46 @@
1
+ import type { MediaResource } from '../types.js';
2
+ type ImageLoaderProps = {
3
+ src: string;
4
+ width: number;
5
+ quality?: number | undefined;
6
+ };
7
+ type ImageLoader = (props: ImageLoaderProps) => string;
8
+ type ValidVariant = {
9
+ url: string;
10
+ width: number;
11
+ };
12
+ /**
13
+ * Finds the best pre-generated variant for a requested width.
14
+ *
15
+ * Strategy:
16
+ * 1. Pick the smallest variant with width >= requested (no quality loss from upscaling)
17
+ * 2. If none is large enough, use the largest variant — but only if it covers >= 80%
18
+ * of the requested width (minor downscale is acceptable, large gap is not)
19
+ * 3. Returns null when no suitable variant exists → caller should fall back to /_next/image
20
+ */
21
+ export declare function findBestVariant(variants: ValidVariant[], requestedWidth: number): ValidVariant | null;
22
+ /**
23
+ * Creates a Next.js Image `loader` that maps requested widths to pre-generated
24
+ * Payload size variants when a close match exists, falling back to the default
25
+ * `/_next/image` optimization pipeline when no suitable variant is available.
26
+ *
27
+ * Returns `undefined` when the media has no usable size variants (i.e. no custom
28
+ * loader needed — let next/image use its default behavior).
29
+ *
30
+ * ```tsx
31
+ * import { createVariantLoader } from '@inoo-ch/payload-image-optimizer/client'
32
+ *
33
+ * const loader = createVariantLoader(media)
34
+ * <NextImage loader={loader} src={media.url} ... />
35
+ * ```
36
+ */
37
+ export declare function createVariantLoader(media: MediaResource): ImageLoader | undefined;
38
+ /**
39
+ * Returns a sensible default `sizes` attribute for responsive images.
40
+ *
41
+ * For `fill` mode images without an explicit `sizes` prop, this prevents the
42
+ * browser from assuming `100vw` (which causes it to always download the
43
+ * largest srcSet variant regardless of actual display area).
44
+ */
45
+ export declare function getDefaultSizes(fill: boolean | undefined): string | undefined;
46
+ export {};
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Extracts usable variants from a Payload media resource's `sizes` field.
3
+ * Filters out entries missing url or width and sorts by width ascending.
4
+ */ function getValidVariants(media) {
5
+ if (!media.sizes) return [];
6
+ return Object.values(media.sizes).filter((v)=>v != null && typeof v.url === 'string' && typeof v.width === 'number').sort((a, b)=>a.width - b.width);
7
+ }
8
+ /**
9
+ * Finds the best pre-generated variant for a requested width.
10
+ *
11
+ * Strategy:
12
+ * 1. Pick the smallest variant with width >= requested (no quality loss from upscaling)
13
+ * 2. If none is large enough, use the largest variant — but only if it covers >= 80%
14
+ * of the requested width (minor downscale is acceptable, large gap is not)
15
+ * 3. Returns null when no suitable variant exists → caller should fall back to /_next/image
16
+ */ export function findBestVariant(variants, requestedWidth) {
17
+ if (variants.length === 0) return null;
18
+ // Smallest variant >= requested width
19
+ const larger = variants.find((v)=>v.width >= requestedWidth);
20
+ if (larger) return larger;
21
+ // No variant large enough — use the largest if it's close
22
+ const largest = variants[variants.length - 1];
23
+ if (largest.width >= requestedWidth * 0.8) return largest;
24
+ return null;
25
+ }
26
+ /**
27
+ * Creates a Next.js Image `loader` that maps requested widths to pre-generated
28
+ * Payload size variants when a close match exists, falling back to the default
29
+ * `/_next/image` optimization pipeline when no suitable variant is available.
30
+ *
31
+ * Returns `undefined` when the media has no usable size variants (i.e. no custom
32
+ * loader needed — let next/image use its default behavior).
33
+ *
34
+ * ```tsx
35
+ * import { createVariantLoader } from '@inoo-ch/payload-image-optimizer/client'
36
+ *
37
+ * const loader = createVariantLoader(media)
38
+ * <NextImage loader={loader} src={media.url} ... />
39
+ * ```
40
+ */ export function createVariantLoader(media) {
41
+ const variants = getValidVariants(media);
42
+ if (variants.length === 0) return undefined;
43
+ const cacheBust = media.updatedAt ? `?${media.updatedAt}` : '';
44
+ return ({ src, width, quality })=>{
45
+ const match = findBestVariant(variants, width);
46
+ if (match) {
47
+ return `${match.url}${cacheBust}`;
48
+ }
49
+ // Fall back to next/image optimization for unmatched widths
50
+ return `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 80}`;
51
+ };
52
+ }
53
+ /**
54
+ * Returns a sensible default `sizes` attribute for responsive images.
55
+ *
56
+ * For `fill` mode images without an explicit `sizes` prop, this prevents the
57
+ * browser from assuming `100vw` (which causes it to always download the
58
+ * largest srcSet variant regardless of actual display area).
59
+ */ export function getDefaultSizes(fill) {
60
+ if (!fill) return undefined;
61
+ return '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw';
62
+ }
63
+
64
+ //# sourceMappingURL=responsiveImage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/responsiveImage.ts"],"sourcesContent":["import type { MediaResource, MediaSizeVariant } from '../types.js'\n\ntype ImageLoaderProps = { src: string; width: number; quality?: number | undefined }\ntype ImageLoader = (props: ImageLoaderProps) => string\n\ntype ValidVariant = { url: string; width: number }\n\n/**\n * Extracts usable variants from a Payload media resource's `sizes` field.\n * Filters out entries missing url or width and sorts by width ascending.\n */\nfunction getValidVariants(media: MediaResource): ValidVariant[] {\n if (!media.sizes) return []\n\n return Object.values(media.sizes)\n .filter((v): v is MediaSizeVariant & { url: string; width: number } =>\n v != null && typeof v.url === 'string' && typeof v.width === 'number',\n )\n .sort((a, b) => a.width - b.width)\n}\n\n/**\n * Finds the best pre-generated variant for a requested width.\n *\n * Strategy:\n * 1. Pick the smallest variant with width >= requested (no quality loss from upscaling)\n * 2. If none is large enough, use the largest variant — but only if it covers >= 80%\n * of the requested width (minor downscale is acceptable, large gap is not)\n * 3. Returns null when no suitable variant exists → caller should fall back to /_next/image\n */\nexport function findBestVariant(\n variants: ValidVariant[],\n requestedWidth: number,\n): ValidVariant | null {\n if (variants.length === 0) return null\n\n // Smallest variant >= requested width\n const larger = variants.find((v) => v.width >= requestedWidth)\n if (larger) return larger\n\n // No variant large enough — use the largest if it's close\n const largest = variants[variants.length - 1]!\n if (largest.width >= requestedWidth * 0.8) return largest\n\n return null\n}\n\n/**\n * Creates a Next.js Image `loader` that maps requested widths to pre-generated\n * Payload size variants when a close match exists, falling back to the default\n * `/_next/image` optimization pipeline when no suitable variant is available.\n *\n * Returns `undefined` when the media has no usable size variants (i.e. no custom\n * loader needed — let next/image use its default behavior).\n *\n * ```tsx\n * import { createVariantLoader } from '@inoo-ch/payload-image-optimizer/client'\n *\n * const loader = createVariantLoader(media)\n * <NextImage loader={loader} src={media.url} ... />\n * ```\n */\nexport function createVariantLoader(media: MediaResource): ImageLoader | undefined {\n const variants = getValidVariants(media)\n if (variants.length === 0) return undefined\n\n const cacheBust = media.updatedAt ? `?${media.updatedAt}` : ''\n\n return ({ src, width, quality }) => {\n const match = findBestVariant(variants, width)\n\n if (match) {\n return `${match.url}${cacheBust}`\n }\n\n // Fall back to next/image optimization for unmatched widths\n return `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 80}`\n }\n}\n\n/**\n * Returns a sensible default `sizes` attribute for responsive images.\n *\n * For `fill` mode images without an explicit `sizes` prop, this prevents the\n * browser from assuming `100vw` (which causes it to always download the\n * largest srcSet variant regardless of actual display area).\n */\nexport function getDefaultSizes(fill: boolean | undefined): string | undefined {\n if (!fill) return undefined\n return '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'\n}\n"],"names":["getValidVariants","media","sizes","Object","values","filter","v","url","width","sort","a","b","findBestVariant","variants","requestedWidth","length","larger","find","largest","createVariantLoader","undefined","cacheBust","updatedAt","src","quality","match","encodeURIComponent","getDefaultSizes","fill"],"mappings":"AAOA;;;CAGC,GACD,SAASA,iBAAiBC,KAAoB;IAC5C,IAAI,CAACA,MAAMC,KAAK,EAAE,OAAO,EAAE;IAE3B,OAAOC,OAAOC,MAAM,CAACH,MAAMC,KAAK,EAC7BG,MAAM,CAAC,CAACC,IACPA,KAAK,QAAQ,OAAOA,EAAEC,GAAG,KAAK,YAAY,OAAOD,EAAEE,KAAK,KAAK,UAE9DC,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEF,KAAK,GAAGG,EAAEH,KAAK;AACrC;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASI,gBACdC,QAAwB,EACxBC,cAAsB;IAEtB,IAAID,SAASE,MAAM,KAAK,GAAG,OAAO;IAElC,sCAAsC;IACtC,MAAMC,SAASH,SAASI,IAAI,CAAC,CAACX,IAAMA,EAAEE,KAAK,IAAIM;IAC/C,IAAIE,QAAQ,OAAOA;IAEnB,0DAA0D;IAC1D,MAAME,UAAUL,QAAQ,CAACA,SAASE,MAAM,GAAG,EAAE;IAC7C,IAAIG,QAAQV,KAAK,IAAIM,iBAAiB,KAAK,OAAOI;IAElD,OAAO;AACT;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,oBAAoBlB,KAAoB;IACtD,MAAMY,WAAWb,iBAAiBC;IAClC,IAAIY,SAASE,MAAM,KAAK,GAAG,OAAOK;IAElC,MAAMC,YAAYpB,MAAMqB,SAAS,GAAG,CAAC,CAAC,EAAErB,MAAMqB,SAAS,EAAE,GAAG;IAE5D,OAAO,CAAC,EAAEC,GAAG,EAAEf,KAAK,EAAEgB,OAAO,EAAE;QAC7B,MAAMC,QAAQb,gBAAgBC,UAAUL;QAExC,IAAIiB,OAAO;YACT,OAAO,GAAGA,MAAMlB,GAAG,GAAGc,WAAW;QACnC;QAEA,4DAA4D;QAC5D,OAAO,CAAC,iBAAiB,EAAEK,mBAAmBH,KAAK,GAAG,EAAEf,MAAM,GAAG,EAAEgB,WAAW,IAAI;IACpF;AACF;AAEA;;;;;;CAMC,GACD,OAAO,SAASG,gBAAgBC,IAAyB;IACvD,IAAI,CAACA,MAAM,OAAOR;IAClB,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
- import React, { useState } from 'react'
3
+ import React, { useMemo, useState } from 'react'
4
4
  import NextImage, { type ImageProps } from 'next/image'
5
5
  import type { MediaResource } from '../types.js'
6
6
  import { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'
7
+ import { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js'
7
8
 
8
9
  export interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {
9
10
  media: MediaResource | string
@@ -44,7 +45,7 @@ export const ImageBox: React.FC<ImageBoxProps> = ({
44
45
  alt={altFromProps || ''}
45
46
  quality={80}
46
47
  fill={fill}
47
- sizes={sizes}
48
+ sizes={sizes ?? getDefaultSizes(fill)}
48
49
  style={{ objectFit: 'cover', objectPosition: 'center', ...fadeStyle, ...styleFromProps }}
49
50
  priority={priority}
50
51
  loading={loading}
@@ -59,6 +60,7 @@ export const ImageBox: React.FC<ImageBoxProps> = ({
59
60
  const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : ''
60
61
 
61
62
  const optimizerProps = getImageOptimizerProps(media)
63
+ const variantLoader = useMemo(() => createVariantLoader(media), [media])
62
64
 
63
65
  return (
64
66
  <NextImage
@@ -69,7 +71,8 @@ export const ImageBox: React.FC<ImageBoxProps> = ({
69
71
  fill={fill}
70
72
  width={!fill ? width : undefined}
71
73
  height={!fill ? height : undefined}
72
- sizes={sizes}
74
+ sizes={sizes ?? getDefaultSizes(fill)}
75
+ loader={variantLoader}
73
76
  style={{ objectFit: 'cover', ...optimizerProps.style, ...fadeStyle, ...styleFromProps }}
74
77
  placeholder={optimizerProps.placeholder}
75
78
  blurDataURL={optimizerProps.blurDataURL}
package/src/defaults.ts CHANGED
@@ -3,7 +3,7 @@ import type { CollectionSlug } from 'payload'
3
3
  import type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'
4
4
 
5
5
  export const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({
6
- clientOptimization: config.clientOptimization ?? false,
6
+ clientOptimization: config.clientOptimization ?? true,
7
7
  collections: config.collections,
8
8
  disabled: config.disabled ?? false,
9
9
  formats: config.formats ?? [
@@ -5,5 +5,8 @@ export { FadeImage } from '../components/FadeImage.js'
5
5
  export type { FadeImageProps } from '../components/FadeImage.js'
6
6
  export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'
7
7
  export type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'
8
+ export { getOptimizedImageProps } from '../utilities/getOptimizedImageProps.js'
9
+ export type { OptimizedImageProps } from '../utilities/getOptimizedImageProps.js'
10
+ export { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js'
8
11
  export { RegenerationButton } from '../components/RegenerationButton.js'
9
12
  export { UploadOptimizer } from '../components/UploadOptimizer.js'
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ import { createConvertFormatsHandler } from './tasks/convertFormats.js'
11
11
  import { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js'
12
12
  import { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js'
13
13
 
14
- export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, FieldsOverride } from './types.js'
14
+ export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, MediaSizeVariant, FieldsOverride } from './types.js'
15
15
  export { defaultImageOptimizerFields } from './fields/imageOptimizerField.js'
16
16
 
17
17
  export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'
package/src/types.ts CHANGED
@@ -46,6 +46,15 @@ export type ImageOptimizerData = {
46
46
  thumbHash?: string | null
47
47
  }
48
48
 
49
+ export type MediaSizeVariant = {
50
+ url?: string | null
51
+ width?: number | null
52
+ height?: number | null
53
+ mimeType?: string | null
54
+ filesize?: number | null
55
+ filename?: string | null
56
+ }
57
+
49
58
  export type MediaResource = {
50
59
  url?: string | null
51
60
  alt?: string | null
@@ -56,4 +65,5 @@ export type MediaResource = {
56
65
  focalY?: number | null
57
66
  imageOptimizer?: ImageOptimizerData | null
58
67
  updatedAt?: string
68
+ sizes?: Record<string, MediaSizeVariant | undefined>
59
69
  }
@@ -0,0 +1,53 @@
1
+ import type { MediaResource } from '../types.js'
2
+ import { getImageOptimizerProps, type ImageOptimizerProps } from './getImageOptimizerProps.js'
3
+ import { createVariantLoader } from './responsiveImage.js'
4
+
5
+ type ImageLoaderProps = { src: string; width: number; quality?: number | undefined }
6
+ type ImageLoader = (props: ImageLoaderProps) => string
7
+
8
+ export type OptimizedImageProps = ImageOptimizerProps & {
9
+ loader?: ImageLoader
10
+ }
11
+
12
+ /**
13
+ * Returns all optimization props for a Next.js `<Image>` component in a single
14
+ * spread-friendly object: ThumbHash blur placeholder, focal-point positioning,
15
+ * and a variant-aware responsive loader.
16
+ *
17
+ * Designed as a drop-in enhancement for the Payload website template's `ImageMedia`:
18
+ *
19
+ * ```tsx
20
+ * // In your ImageMedia component — just add the import and spread:
21
+ * import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
22
+ *
23
+ * const optimizedProps = getOptimizedImageProps(resource)
24
+ *
25
+ * <NextImage
26
+ * {...optimizedProps}
27
+ * src={src}
28
+ * alt={alt}
29
+ * fill={fill}
30
+ * sizes={sizes}
31
+ * priority={priority}
32
+ * loading={loading}
33
+ * />
34
+ * ```
35
+ *
36
+ * What it returns:
37
+ * - `placeholder` / `blurDataURL` — per-image ThumbHash (replaces the template's hardcoded blur)
38
+ * - `style.objectPosition` — focal-point-based positioning
39
+ * - `loader` — hybrid loader that serves pre-generated Payload size variants directly,
40
+ * falling back to `/_next/image` when no close match exists (only present when
41
+ * `resource.sizes` has variants)
42
+ */
43
+ export function getOptimizedImageProps(
44
+ resource: MediaResource | null | undefined,
45
+ ): OptimizedImageProps {
46
+ const base = getImageOptimizerProps(resource)
47
+
48
+ if (!resource) return base
49
+
50
+ const loader = createVariantLoader(resource)
51
+
52
+ return loader ? { ...base, loader } : base
53
+ }
@@ -0,0 +1,91 @@
1
+ import type { MediaResource, MediaSizeVariant } from '../types.js'
2
+
3
+ type ImageLoaderProps = { src: string; width: number; quality?: number | undefined }
4
+ type ImageLoader = (props: ImageLoaderProps) => string
5
+
6
+ type ValidVariant = { url: string; width: number }
7
+
8
+ /**
9
+ * Extracts usable variants from a Payload media resource's `sizes` field.
10
+ * Filters out entries missing url or width and sorts by width ascending.
11
+ */
12
+ function getValidVariants(media: MediaResource): ValidVariant[] {
13
+ if (!media.sizes) return []
14
+
15
+ return Object.values(media.sizes)
16
+ .filter((v): v is MediaSizeVariant & { url: string; width: number } =>
17
+ v != null && typeof v.url === 'string' && typeof v.width === 'number',
18
+ )
19
+ .sort((a, b) => a.width - b.width)
20
+ }
21
+
22
+ /**
23
+ * Finds the best pre-generated variant for a requested width.
24
+ *
25
+ * Strategy:
26
+ * 1. Pick the smallest variant with width >= requested (no quality loss from upscaling)
27
+ * 2. If none is large enough, use the largest variant — but only if it covers >= 80%
28
+ * of the requested width (minor downscale is acceptable, large gap is not)
29
+ * 3. Returns null when no suitable variant exists → caller should fall back to /_next/image
30
+ */
31
+ export function findBestVariant(
32
+ variants: ValidVariant[],
33
+ requestedWidth: number,
34
+ ): ValidVariant | null {
35
+ if (variants.length === 0) return null
36
+
37
+ // Smallest variant >= requested width
38
+ const larger = variants.find((v) => v.width >= requestedWidth)
39
+ if (larger) return larger
40
+
41
+ // No variant large enough — use the largest if it's close
42
+ const largest = variants[variants.length - 1]!
43
+ if (largest.width >= requestedWidth * 0.8) return largest
44
+
45
+ return null
46
+ }
47
+
48
+ /**
49
+ * Creates a Next.js Image `loader` that maps requested widths to pre-generated
50
+ * Payload size variants when a close match exists, falling back to the default
51
+ * `/_next/image` optimization pipeline when no suitable variant is available.
52
+ *
53
+ * Returns `undefined` when the media has no usable size variants (i.e. no custom
54
+ * loader needed — let next/image use its default behavior).
55
+ *
56
+ * ```tsx
57
+ * import { createVariantLoader } from '@inoo-ch/payload-image-optimizer/client'
58
+ *
59
+ * const loader = createVariantLoader(media)
60
+ * <NextImage loader={loader} src={media.url} ... />
61
+ * ```
62
+ */
63
+ export function createVariantLoader(media: MediaResource): ImageLoader | undefined {
64
+ const variants = getValidVariants(media)
65
+ if (variants.length === 0) return undefined
66
+
67
+ const cacheBust = media.updatedAt ? `?${media.updatedAt}` : ''
68
+
69
+ return ({ src, width, quality }) => {
70
+ const match = findBestVariant(variants, width)
71
+
72
+ if (match) {
73
+ return `${match.url}${cacheBust}`
74
+ }
75
+
76
+ // Fall back to next/image optimization for unmatched widths
77
+ return `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 80}`
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Returns a sensible default `sizes` attribute for responsive images.
83
+ *
84
+ * For `fill` mode images without an explicit `sizes` prop, this prevents the
85
+ * browser from assuming `100vw` (which causes it to always download the
86
+ * largest srcSet variant regardless of actual display area).
87
+ */
88
+ export function getDefaultSizes(fill: boolean | undefined): string | undefined {
89
+ if (!fill) return undefined
90
+ return '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
91
+ }