@pantheon-systems/p1-media 0.4.0 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/README.md +0 -322
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantheon-systems/p1-media",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Puck media library plugin backed by Cloudflare R2",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/README.md DELETED
@@ -1,322 +0,0 @@
1
- # @pantheon-systems/p1-media
2
-
3
- A Puck editor plugin that adds a media library, a rich `p1-media` field type, and render
4
- helpers for Puck-based P1 sites. Backed by a Cloudflare Worker + R2 + D1 media store — see
5
- the [monorepo README](https://github.com/pantheon-systems/p1-media-r2/blob/main/README.md) for the backend and the
6
- [API reference](https://github.com/pantheon-systems/p1-media-r2/blob/main/docs/openapi.yaml) for calling the Worker directly.
7
-
8
- The plugin has two distinct audiences with different levels of involvement.
9
-
10
- ---
11
-
12
- ## For component developers
13
-
14
- If you are building a Puck component for a P1 site, you do not need to install or configure the plugin. CCR wires it up automatically when it initialises the Puck editor — `workerUrl`, `siteId`, `workstreamId`, and `getAuthToken` all come from CCR context.
15
-
16
- There are two ways to author an image field:
17
-
18
- - **Basic mode** — name a text field with a standard pattern (below); the picker activates automatically and stores a plain CDN URL string. Render it with `buildImageUrl`.
19
- - **Rich mode** — declare a `p1-media` field; it stores a `MediaValue` object carrying the version URL plus metadata (alt, caption, …). Render it with the `getMediaProps` / `MediaImage` / `MediaFigure` helpers.
20
-
21
- Both are supported; basic mode is preserved indefinitely.
22
-
23
- ### Field naming — auto-detected patterns
24
-
25
- Name your image fields using any of the following patterns and the media library picker will appear automatically in the Puck sidebar:
26
-
27
- - `image`, `imageUrl`
28
- - `logo`, `logoUrl`
29
- - `media`, `mediaUrl`
30
- - `icon`, `iconUrl`
31
- - `thumbnail`, `thumbnailUrl`
32
- - Any field ending in `ImageUrl` or `LogoUrl`
33
-
34
- Navigation URL patterns (`buttonUrl`, `linkUrl`, `ctaUrl`) and alt text fields are excluded.
35
-
36
- ### Declaring a rich media field (`p1-media`)
37
-
38
- The quickest integration is the ready-made component the plugin ships:
39
-
40
- ```tsx
41
- import { createMediaFigureBlock } from "@pantheon-systems/p1-media";
42
-
43
- const config = {
44
- components: {
45
- MediaFigureBlock: createMediaFigureBlock({
46
- mediaBaseUrl: "https://media.p1.pantheon.io",
47
- }),
48
- },
49
- };
50
- ```
51
-
52
- `createMediaFigureBlock` options:
53
-
54
- | Option | Default | Description |
55
- |--------|---------|-------------|
56
- | `mediaBaseUrl` | `"https://media.p1.pantheon.io"` (production) | CDN image origin used to validate value URLs — NOT the Worker API URL. Override for sandbox/staging/local dev |
57
- | `transform` | `{ width: 1200, height: 630, format: "auto" }` | Render-time transform; keep both dimensions (see crop note below) |
58
- | `label` | `"Media Figure"` | Component label in the Puck sidebar |
59
- | `fieldLabel` | `"Photo"` | Label of the media field |
60
- | `schema` | — | `MetadataFieldDef[]` passed to `MediaFigure`; pins figcaption field order/labels |
61
- | `className` / `captionClassName` | — | Forwarded to `MediaFigure` |
62
- | `placeholder` | `"Choose a photo from the media library"` | Rendered when no photo is chosen (or its URL fails validation) |
63
-
64
- To put the field on your own component instead, declare it directly. `p1-media` is
65
- registered by the plugin at editor runtime, so it is not part of Puck's built-in `Field`
66
- union — cast the declaration:
67
-
68
- ```tsx
69
- import type { Field } from "@puckeditor/core";
70
- import { MediaImage, type MediaFieldValue } from "@pantheon-systems/p1-media";
71
-
72
- const heroBlock = {
73
- fields: {
74
- photo: { type: "p1-media", label: "Photo" } as unknown as Field,
75
- },
76
- defaultProps: { photo: null },
77
- render: ({ photo }: { photo?: MediaFieldValue | null }) => (
78
- <MediaImage image={photo} mediaBaseUrl={MEDIA_BASE} transform={{ width: 1200, height: 630 }} />
79
- ),
80
- };
81
- ```
82
-
83
- ### Uploading with metadata
84
-
85
- Choosing or dropping files in the library modal stages them in a metadata grid before
86
- anything is uploaded: one row per image, one column per schema field (alt, caption, …).
87
- Filling it is optional — Upload sends whatever is non-empty as metadata defaults on each
88
- asset. Every item's ✎ button opens the same grid to edit an existing asset's metadata
89
- (empty cells clear the field via `PATCH /media/:assetId`).
90
-
91
- The grid is keyboard-first: Tab moves through cells, Enter/↓ and ↑ move within a column.
92
- Pasting multi-cell TSV or CSV (e.g. copied from a spreadsheet) fills the grid from the
93
- focused cell. If the first pasted row is a header naming schema fields (`alt`, `caption`,
94
- …), columns are mapped by name instead — and with a `filename` column, rows are matched to
95
- the staged files by filename, so a whole exported sheet can be pasted into any cell.
96
-
97
- The edit panel also has **Replace image…**, which uploads a new file as a new immutable
98
- version of the same asset (`POST /media/:assetId/versions`) — placements pinned to older
99
- versions keep serving; metadata is untouched.
100
-
101
- The library grid itself is keyboard-navigable (roving tab stop: arrow keys move between
102
- tiles, Enter/Space selects, E edits, Delete removes) and lazily rendered — tiles are
103
- revealed in batches of 60 as you scroll, images load with `loading="lazy"`, and the list
104
- fetches up to the Worker's 500-item cap (use search beyond that).
105
-
106
- ### Cropping
107
-
108
- Crop intent always lives in the value's URL query — the stored value stays a plain CDN URL
109
- (basic mode) or a `MediaValue` whose `url` carries the params (rich mode):
110
-
111
- | Editor control | URL params | Notes |
112
- |----------------|------------|-------|
113
- | Fit in | `?fit=scale-down` | Default; never crops |
114
- | Smart crop | `?fit=cover&gravity=auto` | Content-aware crop toward the render transform's aspect ratio |
115
- | Custom… (rich field only) | `?trim.left=…&trim.top=…&trim.width=…&trim.height=…` | Interactive cropper with fixed (1:1, 4:3, 3:2, 16:9) or free aspect ratios; values are source-image pixels |
116
-
117
- The render-time `transform` composes on top of the crop: a `trim` region is cut from the
118
- source first, then resized to `width`/`height`. See the
119
- [image transformation params](https://github.com/pantheon-systems/p1-media-r2/blob/main/docs/openapi.yaml)
120
- in the API reference for everything the `/image` route accepts.
121
-
122
- ### Rendering — basic mode (string value)
123
-
124
- The stored value is a clean CDN URL. Use `buildImageUrl` to add size, format, and quality at render time; it preserves the editor's crop intent (`?fit=…&gravity=…`).
125
-
126
- ```tsx
127
- import { buildImageUrl } from "@pantheon-systems/p1-media";
128
-
129
- <img src={buildImageUrl(data.heroImage, { width: 1200, height: 630, format: "webp" })} />
130
- <img src={buildImageUrl(data.thumbnail, { width: 150, height: 150, format: "webp", quality: 80 })} />
131
- ```
132
-
133
- ### Rendering — rich mode (`p1-media` value)
134
-
135
- The stored value is a `MediaValue` object. Use the render helpers, which return/apply the
136
- alt text and captured dimensions. **`mediaBaseUrl`** (the CDN image origin) is required and
137
- passed at render — the helpers run in RSC and can't read plugin config, and they fail
138
- closed (empty `src`) for any URL not on that origin. URLs must be `https`, with one
139
- local-dev exception: when the configured base is itself `http` on a loopback host
140
- (`http://localhost:8788` — a local `wrangler dev` worker), same-origin `http` URLs are
141
- allowed so rich values render locally. Production is unaffected (real CDN bases are `https`).
142
-
143
- ```tsx
144
- import { MediaImage, MediaFigure, getMediaProps } from "@pantheon-systems/p1-media";
145
-
146
- const MEDIA_BASE = "https://media.p1.pantheon.io";
147
-
148
- // <img>-like wrapper: src + alt + width/height come from the value
149
- <MediaImage image={data.heroImage} mediaBaseUrl={MEDIA_BASE} transform={{ width: 1200, height: 630, format: "webp" }} />
150
-
151
- // figure + caption/credit/byline composed generically from the metadata schema
152
- <MediaFigure image={data.heroImage} mediaBaseUrl={MEDIA_BASE} transform={{ width: 1200, height: 630, format: "webp" }} />
153
-
154
- // or spread the raw props onto your own element / next/image
155
- const { src, alt, width, height } = getMediaProps(data.heroImage, { mediaBaseUrl: MEDIA_BASE, transform: { width: 1200, height: 630 } });
156
- ```
157
-
158
- > Pass **both** `width` and `height` in the transform. The editor's crop toggle is carried
159
- > as `?fit=cover&gravity=auto` (smart crop) vs `?fit=scale-down` (fit in), and `fit` only
160
- > changes the output when there is a target aspect ratio to crop against — with a
161
- > width-only transform both modes produce identical pixels and the crop control appears
162
- > to do nothing.
163
-
164
- All three helpers accept `string | MediaValue | null | undefined` and render nothing
165
- (`MediaImage`/`MediaFigure` return `null`; `getMediaProps` returns `src: ""`) when the
166
- value is empty or its URL fails origin validation — branch on that for a placeholder.
167
- Full option surface:
168
-
169
- **`getMediaProps(value, options)`** → `{ src, alt, width?, height? }`
170
-
171
- | Option | Description |
172
- |--------|-------------|
173
- | `mediaBaseUrl` | CDN image origin; validation fails closed without it |
174
- | `transform` | `ImageTransformParams` — `{ width?, height?, format? ("auto" \| "webp" \| "jpeg" \| "png" \| "gif" \| "avif"), quality? }`, merged onto the validated URL, preserving the editor's crop params |
175
-
176
- Spread the result onto your own element or `next/image` (with `remotePatterns` set to the
177
- CDN origin — `next/image` fetches server-side).
178
-
179
- **`<MediaImage />`** props
180
-
181
- | Prop | Description |
182
- |------|-------------|
183
- | `image` | The field value |
184
- | `mediaBaseUrl` / `transform` | As above |
185
- | `alt` | Overrides the value's alt text |
186
- | …anything else | All other `<img>` attributes (`className`, `loading`, `sizes`, …) pass through; `width`/`height` come from the value's captured dimensions |
187
-
188
- **`<MediaFigure />`** props
189
-
190
- | Prop | Description |
191
- |------|-------------|
192
- | `image` | The field value |
193
- | `mediaBaseUrl` / `transform` | As above |
194
- | `schema` | `MetadataFieldDef[]` (e.g. the fetched `GET /media/schema`) — pins figcaption field order and labels. Without it, the value's own string metadata keys render in key order, which can shift across saves |
195
- | `className` / `captionClassName` | Styling hooks for the `<figure>` and `<figcaption>` |
196
-
197
- `alt` renders on the `<img>`; every other non-empty string metadata field renders as an
198
- escaped `<span data-field="…">` inside the `<figcaption>`.
199
-
200
- ### Stored field value formats
201
-
202
- Basic mode — a CDN URL (new keys are `{siteId}/assets/{assetId}/{versionId}-{filename}`; the editor's crop is `?fit=cover&gravity=auto` for smart crop, `?fit=scale-down` for fit-in; a rich value's `url` may instead carry `?trim.…` from the custom cropper):
203
-
204
- ```
205
- https://media.p1.pantheon.io/image/{siteId}/assets/{assetId}/{versionId}-{filename}?fit=cover&gravity=auto
206
- ```
207
-
208
- Rich mode — a `MediaValue` object:
209
-
210
- ```jsonc
211
- {
212
- "assetId": "…", "versionId": "…",
213
- "url": "https://media.p1.pantheon.io/image/{siteId}/assets/{assetId}/{versionId}-{filename}",
214
- "width": 1600, "height": 900,
215
- "alt": "A red barn at sunset",
216
- "metaSchemaVersion": 1
217
- }
218
- ```
219
-
220
- Legacy documents may still hold the old `{siteId}/{workstreamId}/media/{timestamp}-{filename}` URL form; the render helpers accept it (as a string) unchanged.
221
-
222
- ---
223
-
224
- ## For site developers enabling the media library
225
-
226
- If you are building a P1-powered site and want editors to have a media library in the Puck sidebar, install this package and add the media plugin alongside the standard CCR editor setup. `siteId` and `getAuthToken` are read automatically from the ambient `@pantheon-systems/puck-css` context (`P1PuckProvider` / `P1AuthProvider`) when the plugin is rendered inside a standard CCR editor — no wiring needed for those two. `workerUrl` and `workstreamId` are both optional too (see the options table below).
227
-
228
- ### Install
229
-
230
- ```sh
231
- pnpm add @pantheon-systems/p1-media
232
- ```
233
-
234
- ### Integration with puck-css
235
-
236
- Pass the media plugin via `additionalPlugins` in `useP1Editor`. The hook handles stable plugin merging internally — no manual override wiring needed.
237
-
238
- ```tsx
239
- import { Puck } from "@puckeditor/core";
240
- import { createMediaPlugin } from "@pantheon-systems/p1-media";
241
- import { useP1Editor } from "@pantheon-systems/puck-css";
242
-
243
- // siteId and getAuthToken are read from the ambient P1PuckProvider /
244
- // P1AuthProvider automatically — no need to pass or memoize them yourself.
245
- const mediaPlugin = createMediaPlugin({});
246
-
247
- function Editor({ documentPath, config }) {
248
- const { loading, error, puckKey, puckProps } = useP1Editor({
249
- documentPath,
250
- puckConfig: config,
251
- additionalPlugins: [mediaPlugin],
252
- });
253
-
254
- if (loading) return null;
255
- if (error) return <div>Error: {error.message}</div>;
256
- return <Puck key={puckKey} {...puckProps} />;
257
- }
258
- ```
259
-
260
- Rendering outside a `P1PuckProvider`/`P1AuthProvider` (or overriding either value) still works — pass `siteId`/`getAuthToken` explicitly and they take precedence over the ambient context:
261
-
262
- ```tsx
263
- import { useMemo } from "react";
264
- import { createMediaPlugin } from "@pantheon-systems/p1-media";
265
- import { useP1Auth } from "@pantheon-systems/puck-css";
266
-
267
- function Editor({ siteId }) {
268
- const { getToken } = useP1Auth();
269
- const mediaPlugin = useMemo(
270
- () => createMediaPlugin({ siteId, getAuthToken: getToken }),
271
- [siteId, getToken],
272
- );
273
- // ...
274
- }
275
- ```
276
-
277
- ### `createMediaPlugin` options
278
-
279
- | Option | Type | Description |
280
- |--------|------|-------------|
281
- | `workerUrl` | `string` (optional) | Base URL of the deployed p1-media Worker. Defaults to the production host (`https://media.p1.pantheon.io`) — override for sandbox/staging/local dev |
282
- | `siteId` | `string` (optional) | Site UUID. Defaults to the ambient puck-css `P1PuckProvider` context — pass explicitly only to override it, or when rendering outside that provider |
283
- | `workstreamId` | `string` (optional) | Accepted for forward-compat; the site-scoped worker doesn't read it for any scoping decision today, so there's no need to pass it |
284
- | `getAuthToken` | `() => Promise<string \| null> \| string \| null` (optional) | Returns the CCR auth bearer token. Defaults to the ambient puck-css `P1AuthProvider` context's `getToken` — pass explicitly only to override it, or when rendering outside that provider |
285
- | `fieldNamePatterns` | `RegExp[]` | Override the default field name patterns |
286
- | `metadataFields` | `MetadataFieldDef[]` | Fallback metadata schema for the `p1-media` field when `GET /media/schema` is unavailable (defaults to `[{ name: "alt", label: "Alt text", type: "string" }]`) |
287
-
288
- `@pantheon-systems/puck-css` (`>=0.4.0`) is a required peer dependency as of this
289
- version, matching `@pantheon-systems/p1-ai-chat`'s convention — every real
290
- consumer of this plugin is already a P1/CCR site that depends on it.
291
-
292
- ---
293
-
294
- ## Exports
295
-
296
- ```ts
297
- import {
298
- createMediaPlugin, // Plugin factory (for CCR integration)
299
- createMediaFigureBlock, // Ready-made Puck component: p1-media field + MediaFigure render
300
- buildImageUrl, // Apply transform params to a CDN URL string (basic mode)
301
- getMediaProps, // (value, { mediaBaseUrl, transform }) -> { src, alt, width?, height? }
302
- MediaImage, // <img>-like component for a MediaValue
303
- MediaFigure, // figure + schema-driven caption/credit/byline
304
- makeMediaValue, // build a MediaValue (enforces the assetId+versionId invariant)
305
- isMediaValue, // narrow string | MediaValue
306
- DEFAULT_MEDIA_PATTERNS, // default field-name patterns
307
- } from "@pantheon-systems/p1-media";
308
-
309
- import type {
310
- MediaPluginOptions, // createMediaPlugin config shape
311
- MediaFigureBlockOptions, // createMediaFigureBlock config shape
312
- MediaFigureBlockProps, // { photo: MediaFieldValue | null }
313
- ImageTransformParams, // { width?, height?, format? (incl. "auto"), quality? }
314
- MediaValue, // rich field value: { assetId, versionId, url, alt?, ... }
315
- MediaFieldValue, // string | MediaValue
316
- MediaProps, // getMediaProps return shape
317
- MetadataFieldDef, // { name, label, type, required? }
318
- GetMediaPropsOptions, // { mediaBaseUrl, transform? }
319
- } from "@pantheon-systems/p1-media";
320
- ```
321
-
322
- The server-safe subset (`buildImageUrl`, `getMediaProps`, `MediaImage`, `MediaFigure`, `createMediaFigureBlock`, `makeMediaValue`, `isMediaValue`, and the types) is also exported from the package's `react-server` entry for use in RSC. The interactive cropper (`react-image-crop`) is bundled into the client entry with its styles injected at runtime — consumers install nothing extra.