@inoo-ch/payload-image-optimizer 1.5.1 → 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 +179 -38
- package/README.md +53 -19
- package/dist/components/ImageBox.js +8 -3
- package/dist/components/ImageBox.js.map +1 -1
- package/dist/exports/client.d.ts +3 -0
- package/dist/exports/client.js +2 -0
- package/dist/exports/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.js.map +1 -1
- package/dist/utilities/getOptimizedImageProps.d.ts +44 -0
- package/dist/utilities/getOptimizedImageProps.js +43 -0
- package/dist/utilities/getOptimizedImageProps.js.map +1 -0
- package/dist/utilities/responsiveImage.d.ts +46 -0
- package/dist/utilities/responsiveImage.js +64 -0
- package/dist/utilities/responsiveImage.js.map +1 -0
- package/package.json +1 -1
- package/src/components/ImageBox.tsx +6 -3
- package/src/exports/client.ts +3 -0
- package/src/index.ts +1 -1
- package/src/types.ts +10 -0
- package/src/utilities/getOptimizedImageProps.ts +53 -0
- package/src/utilities/responsiveImage.ts +91 -0
package/AGENT_DOCS.md
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
230
|
-
<ImageBox media={doc.
|
|
229
|
+
// Pass the full Payload media document — ImageBox handles everything
|
|
230
|
+
<ImageBox media={doc.heroImage} alt="Hero" fill priority />
|
|
231
231
|
|
|
232
|
-
//
|
|
233
|
-
<ImageBox media=
|
|
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
|
-
//
|
|
236
|
-
<ImageBox media={doc.
|
|
235
|
+
// Fixed dimensions (non-fill)
|
|
236
|
+
<ImageBox media={doc.avatar} alt="Avatar" width={64} height={64} fade={false} />
|
|
237
237
|
|
|
238
|
-
//
|
|
239
|
-
<ImageBox media=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
267
|
+
import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
|
|
264
268
|
|
|
265
|
-
|
|
269
|
+
// In your existing ImageMedia component — 3 lines to add:
|
|
270
|
+
const optimizedProps = getOptimizedImageProps(resource)
|
|
266
271
|
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
@@ -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
|
-
##
|
|
223
|
+
## Displaying Images
|
|
222
224
|
|
|
223
|
-
|
|
225
|
+
### Option 1: `ImageBox` (New Projects)
|
|
226
|
+
|
|
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
|
-
//
|
|
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
|
-
|
|
232
|
-
<ImageBox media="/images/photo.jpg" alt="Photo" width={800} height={600} />
|
|
250
|
+
### Option 2: `getOptimizedImageProps()` (Existing Projects / Payload Website Template)
|
|
233
251
|
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
314
|
-
> 4.
|
|
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;
|
|
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/exports/client.d.ts
CHANGED
|
@@ -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/dist/exports/client.js
CHANGED
|
@@ -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":"
|
|
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.
|
|
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/exports/client.ts
CHANGED
|
@@ -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
|
+
}
|