Skip to content

feat(admin): add media_picker BlockKit element#731

Merged
ascorbic merged 5 commits intoemdash-cms:mainfrom
drudge:feat/admin-blockkit-media-picker
Apr 30, 2026
Merged

feat(admin): add media_picker BlockKit element#731
ascorbic merged 5 commits intoemdash-cms:mainfrom
drudge:feat/admin-blockkit-media-picker

Conversation

@drudge
Copy link
Copy Markdown
Contributor

@drudge drudge commented Apr 22, 2026

What does this PR do?

Adds a media_picker Block Kit element: a thumbnail preview with a modal library picker and mime-type filter. Usable in plugin block forms (inside PortableTextEditor) and as a Block Kit field widget (BlockKitFieldWidget).

The stored value is the selected asset's URL string, so it is value-compatible with a plain text_input — existing content continues to work after swapping.

This is the third and final piece of the original #679 split, per @ascorbic's review feedback. See discussion #337 for context.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Discussion: #337

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code

What's included

  • @emdash-cms/blocks:
    • MediaPickerElement type (action_id + label + optional mime_type_filter / initial_value / placeholder)
    • elements.mediaPicker(...) builder
    • Validation for the media_picker shape
    • Adds media_picker case to runtime renderElement (admin-authoring only — returns null at runtime)
  • @emdash-cms/admin:
    • BlockKitMediaPicker rendering inside PortableTextEditor (for plugin blocks that use fields)
    • MediaPickerWidget case in BlockKitFieldWidget (for plugin field widgets outside the PT editor)
    • Thumbnail + hover-revealed Change / Remove controls; placeholder button when empty
    • MediaPickerModal integration with mime_type_filter (defaults to image/)
    • Local provider asset URLs are resolved via /_emdash/api/media/file/... so previews work pre-CDN
    • New user-visible strings wrapped in <Trans>/t and extracted via pnpm locale:extract
  • Changeset: minor bump for emdash, @emdash-cms/admin, @emdash-cms/blocks

Related

Copilot AI review requested due to automatic review settings April 22, 2026 21:17
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 22, 2026

🦋 Changeset detected

Latest commit: 6091cd1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/blocks Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/plugin-embeds Patch
@emdash-cms/auth Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

Scope check

This PR changes 641 lines across 20 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
packages/admin/src/locales/ar/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/de/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/en/messages.po Source changed, localizations will be marked as outdated.
packages/admin/src/locales/es-419/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/eu/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/fa/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/fr/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ja/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ko/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pl/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pseudo/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pt-BR/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/zh-CN/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/zh-TW/messages.po Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 22, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@731

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@731

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@731

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@731

emdash

npm i https://pkg.pr.new/emdash@731

create-emdash

npm i https://pkg.pr.new/create-emdash@731

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@731

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@731

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@731

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@731

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@731

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@731

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@731

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@731

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@731

commit: 6091cd1

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new media_picker Block Kit element across @emdash-cms/blocks (types/builders/validation) and @emdash-cms/admin (rendering in plugin block forms and BlockKit field widgets), plus locale catalog updates and a changeset.

Changes:

  • Add MediaPickerElement to the Block Kit element model, builder API, and shape validation.
  • Implement media_picker UI in the admin: thumbnail preview + MediaPickerModal integration (PT editor + field widget).
  • Update admin locale catalogs and bump packages via changeset.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
packages/blocks/src/validation.ts Recognizes and validates media_picker element shape.
packages/blocks/src/types.ts Adds MediaPickerElement and extends the Element union.
packages/blocks/src/index.ts Re-exports MediaPickerElement.
packages/blocks/src/builders.ts Adds elements.mediaPicker(...) builder.
packages/admin/src/components/PortableTextEditor.tsx Renders media_picker fields inside plugin block forms.
packages/admin/src/components/BlockKitFieldWidget.tsx Adds media_picker widget for non-PT BlockKit field contexts.
packages/admin/src/locales/en/messages.po Extracted new strings / updated references.
packages/admin/src/locales/ar/messages.po Extracted new strings / updated references.
packages/admin/src/locales/de/messages.po Extracted new strings / updated references.
packages/admin/src/locales/es-419/messages.po Extracted new strings / updated references.
packages/admin/src/locales/eu/messages.po Extracted new strings / updated references.
packages/admin/src/locales/fa/messages.po Extracted new strings / updated references.
packages/admin/src/locales/fr/messages.po Extracted new strings / updated references.
packages/admin/src/locales/ja/messages.po Extracted new strings / updated references.
packages/admin/src/locales/ko/messages.po Extracted new strings / updated references.
packages/admin/src/locales/pseudo/messages.po Extracted new strings / updated references.
packages/admin/src/locales/pt-BR/messages.po Extracted new strings / updated references.
packages/admin/src/locales/zh-CN/messages.po Extracted new strings / updated references.
packages/admin/src/locales/zh-TW/messages.po Extracted new strings / updated references.
.changeset/blockkit-media-picker.md Publishes the feature via a minor version bump changeset.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/admin/src/components/BlockKitFieldWidget.tsx Outdated
Comment thread packages/blocks/src/validation.ts
Comment thread packages/admin/src/components/PortableTextEditor.tsx Outdated
Comment thread packages/blocks/src/types.ts
Comment thread packages/admin/src/components/BlockKitFieldWidget.tsx Outdated
Comment thread packages/admin/src/components/PortableTextEditor.tsx Outdated
Comment thread packages/admin/src/components/BlockKitFieldWidget.tsx Outdated
Comment thread packages/admin/src/components/PortableTextEditor.tsx Outdated
Comment thread packages/admin/src/components/PortableTextEditor.tsx Outdated
Comment thread packages/admin/src/components/BlockKitFieldWidget.tsx Outdated
drudge added a commit to drudge/emdash that referenced this pull request Apr 22, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types (text/number/select/
  toggle/checkbox/combobox/date/radio as sub-fields — no nested
  repeaters, no buttons)
- `elements.repeater(...)` builder
- Schema validation for the repeater: sub-field type whitelist,
  non-negative integer `min_items` / `max_items` with
  `min_items <= max_items`
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
@github-actions
Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

drudge added a commit to drudge/emdash that referenced this pull request Apr 23, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types. Sub-fields are
  restricted to the four scalar inputs the admin widget renders
  inline: `text_input`, `number_input`, `select`, `toggle`. No
  nested repeaters, no buttons.
- `elements.repeater(...)` builder
- Schema validation for the repeater: non-empty `fields` array,
  sub-field type whitelist, non-negative integer `min_items` /
  `max_items` with `min_items <= max_items`, and `initial_value`
  entries must be objects
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- `handleRemove` drops the removed item's key from the `expanded`
  Set so it doesn't retain stale entries
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
drudge added a commit to drudge/emdash that referenced this pull request Apr 24, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types. Sub-fields are
  restricted to the four scalar inputs the admin widget renders
  inline: `text_input`, `number_input`, `select`, `toggle`. No
  nested repeaters, no buttons.
- `elements.repeater(...)` builder
- Schema validation for the repeater: non-empty `fields` array,
  sub-field type whitelist, non-negative integer `min_items` /
  `max_items` with `min_items <= max_items`, and `initial_value`
  entries must be objects
- Unit tests in `tests/validation.test.ts` covering the new rules
  (valid repeater, empty fields, disallowed sub-field type,
  non-integer / inverted min/max, malformed `initial_value`)
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- `handleRemove` drops the removed item's key from the `expanded`
  Set so it doesn't retain stale entries
- Item-card summary trims the first text_input before falling back
  to "Item N" so whitespace-only values still show the index
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
drudge added a commit to drudge/emdash that referenced this pull request Apr 24, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types. Sub-fields are
  restricted to the four scalar inputs the admin widget renders
  inline: `text_input`, `number_input`, `select`, `toggle`. No
  nested repeaters, no buttons.
- `elements.repeater(...)` builder
- Schema validation for the repeater: non-empty `fields` array,
  sub-field type whitelist, non-negative integer `min_items` /
  `max_items` with `min_items <= max_items`, and `initial_value`
  entries must be objects
- Unit tests in `tests/validation.test.ts` covering the new rules
  (valid repeater, empty fields, disallowed sub-field type,
  non-integer / inverted min/max, malformed `initial_value`)
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- `handleRemove` drops the removed item's key from the `expanded`
  Set so it doesn't retain stale entries
- Item-card summary trims the first text_input before falling back
  to "Item N" so whitespace-only values still show the index
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
@drudge drudge force-pushed the feat/admin-blockkit-media-picker branch from 0043612 to 73d8fad Compare April 24, 2026 14:15
@drudge drudge force-pushed the feat/admin-blockkit-media-picker branch from 73d8fad to 25da9c8 Compare April 28, 2026 20:08
drudge added 3 commits April 28, 2026 16:08
Adds a `media_picker` Block Kit element: a thumbnail preview with a modal
library picker and mime-type filter. Usable in plugin block forms (inside
`PortableTextEditor`) and as a Block Kit field widget (`BlockKitFieldWidget`).

The stored value is the selected asset's URL string, so it is value-compatible
with a plain `text_input` — existing content continues to work after swapping.

Split from emdash-cms#679 per maintainer request. Image-link landed in emdash-cms#704; repeater
landed in the rescoped emdash-cms#679.
Add a `case "media_picker"` arm to keep the Element union exhaustive.
The picker is an admin-authoring construct (thumbnail + modal library
picker) with no runtime render semantics, so returning null matches
the repeater pattern.
@drudge drudge force-pushed the feat/admin-blockkit-media-picker branch from 25da9c8 to 14410e6 Compare April 28, 2026 20:09
@ascorbic
Copy link
Copy Markdown
Collaborator

@drudge the Copilot comments are mostly valid, so could you address them? Additionally, mimeTypeFilter should have more validation

@drudge
Copy link
Copy Markdown
Contributor Author

drudge commented Apr 29, 2026

Will do @ascorbic!

…back

- Extract shared BlockKitMediaPickerField; both BlockKitFieldWidget and
  PortableTextEditor now render through it, killing two near-identical
  copies that risked diverging.
- Fix URL-insert local-rewrite bug: MediaPickerModal returns URL-inserted
  items with id:"" and no provider/storageKey. Treating the absence of
  provider as "local" rewrote external URLs to a broken
  /_emdash/api/media/file/ path. Detect local explicitly via
  provider==="local" || !!storageKey and fall through to item.url.
- Validate URLs before previewing: only render <img> for safe http(s)
  URLs or relative paths starting with "/" (not "//"); fall back to the
  empty-state placeholder otherwise. Add referrerPolicy="no-referrer"
  and loading="lazy" on the preview <img>.
- Improve a11y on hover-revealed Change/Remove controls: also reveal on
  group-focus-within, and toggle pointer-events with the same group
  states so invisible controls don't absorb pointer events.
- Restrict mime_type_filter to image MIME types (image/ or image/<sub>),
  rejecting wildcards like image/* (unsupported by the picker's
  startsWith filter) and non-image types like video/.
- Add validation tests (3 valid + 6 invalid cases) and component tests
  (11 cases covering empty state, picker open, local pick, URL pick,
  preview attrs, unsafe-URL fallback, remove).
@drudge
Copy link
Copy Markdown
Contributor Author

drudge commented Apr 30, 2026

@ascorbic — addressed in 6091cd1. Summary of each item:

  • Shared component — extracted BlockKitMediaPickerField (packages/admin/src/components/BlockKitMediaPickerField.tsx); both BlockKitFieldWidget and PortableTextEditor's BlockKitField switch now render through it.
  • mime_type_filter validation — restricted to image MIME types (image/ or image/<subtype>). Rejects wildcards (image/*) and non-image types (video/, application/pdf) via a new RFC 6838-aligned regex in packages/blocks/src/validation.ts. Error message: "Field 'mime_type_filter' must be an image MIME type or prefix, e.g. 'image/' or 'image/png' (wildcards and non-image types are not supported)".
  • URL-insert local-rewrite bugMediaPickerModal returns URL-inserted items with id: "" and no provider/storageKey. The old !item.provider || provider === "local" check rewrote them to a broken /_emdash/api/media/file/ path. Now: isLocalMedia = provider === "local" || !!storageKey with a fall-through to item.url.
  • <img src> safety — added an isSafePreviewUrl guard that only previews safe http(s) URLs or /-rooted relative paths; rejects javascript:, data:, protocol-relative //host, etc. and falls back to the empty-state placeholder. Also sets referrerPolicy="no-referrer" and loading="lazy" on the <img>.
  • Hover-only controls a11y — added pointer-events-none / group-hover:pointer-events-auto / group-focus-within:opacity-100 / group-focus-within:pointer-events-auto, so Change/Remove are reachable via keyboard focus.
  • Validation tests — 9 new cases (3 valid + 6 invalid) in packages/blocks/tests/validation.test.ts.
  • Component tests — 11 new cases in packages/admin/tests/components/BlockKitMediaPickerField.test.tsx covering empty state, picker open, local pick, URL-insert pick, preview attributes (including referrerpolicy/loading), unsafe-URL fallback (javascript:, //host, data:), and remove.

One Copilot comment was a false positive — it claimed packages/blocks/src/render-element.tsx was missing case "media_picker", but the PR already adds case "media_picker": return null; (lines 57–58). Marked that thread as resolved with a note.

Local checks: typecheck ✓, oxlint --type-aware 0 errors, format:check ✓, @emdash-cms/blocks test 93 pass, @emdash-cms/admin test 810 pass.

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid follow-up to the Copilot review — all the substantive items are addressed: shared component extraction, image-only mime_type_filter validation, the URL-insert local-rewrite bug, <img src> scheme guard with referrerPolicy="no-referrer", focus-reachable hover controls, and dedicated tests for both the validator and the field component.

A few small observations below — none blocking.

// `provider`/`storageKey`, so we cannot infer "local" from absence of
// `provider` alone — that would rewrite the external URL to a broken
// `/_emdash/api/media/file/` path. Detect local explicitly.
const isLocalMedia = item.provider === "local" || !!item.storageKey;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this branch implicitly assumes storageKey is only ever set on local items. The MediaItem JSDoc backs that up today ("Storage key for local media... Not present for external URLs."), but if a future provider ever populates storageKey it will silently get rewritten to /_emdash/api/media/file/<key> and break.

A slightly more defensive form keeps today's behavior but doesn't depend on absence:

Suggested change
const isLocalMedia = item.provider === "local" || !!item.storageKey;
const isLocalMedia = item.provider === "local" || (!item.provider && !!item.storageKey);
const localKey = item.storageKey || item.id;
const nextUrl = isLocalMedia && localKey ? `/_emdash/api/media/file/${localKey}` : item.url;

Up to you — happy to leave as-is given the documented contract.

className="max-h-40 w-full rounded-md border border-kumo-line object-contain bg-kumo-muted"
referrerPolicy="no-referrer"
loading="lazy"
/>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an existing value passes the scheme/path safety check but the resource isn't actually loadable as an image (e.g. a leftover text_input value that points at a PDF, a deleted asset, or a plain HTML page), the user gets a broken-image icon with no Change/Remove affordance unless they happen to hover/focus the broken <img>. Consider an onError that falls back to the empty-state placeholder so the controls are always reachable. Not critical — fully recoverable today by tabbing to the hidden Remove button — but worth a follow-up.

<div className="relative group">
<img
src={url}
alt=""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the stored value is just a URL string, so there's no captioning/metadata available, but alt="" marks the image as decorative. For a media-picker preview the image is the content for an SR user. Consider alt={label} (or alt={t\Selected media``}) so screen reader users at least know there's a selected asset and what field it belongs to.

* Accepts 'image/', 'image/png', 'image/svg+xml'. Rejects 'image/*'
* (wildcards) and non-image types like 'video/' or 'application/pdf'.
*/
const MEDIA_PICKER_MIME_FILTER_RE = /^image\/(?:[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126})?$/;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex is case-sensitive on image/, but MediaPickerModal does item.mimeType.startsWith(mimeTypeFilter), which is also case-sensitive. RFC 6838 type/subtype names are case-insensitive in practice, so a plugin author writing mime_type_filter: "Image/PNG" will fail validation here, and even if it didn't, would silently match nothing in the modal. Consider documenting (or normalizing) that the filter is expected lowercase, since the validator's the natural place to enforce it.

(screen.getByText("open").element() as HTMLElement).closest("button")!.click();
await expect.element(screen.getByTestId("media-picker-modal")).toBeInTheDocument();
(screen.getByText("pick-local").element() as HTMLElement).click();
expect(onChange).toHaveBeenCalledWith("hero", "/_emdash/api/media/file/photo.png");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap: there's no test for an external-provider item (e.g. provider: "cloudflare-images", no storageKey) where the modal returns a fully-formed CDN url. The current pick-local and pick-url cases cover the two boundary conditions, but a third case — provider set to something non-"local" — would lock in the behavior that the rewrite branch only fires for local items, which is the whole point of the bug fix. A one-liner addition.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review posted successfully. Summary of feedback (all minor/nice-to-have, nothing blocking):

  1. Defensive isLocalMedia check — suggested guarding the storageKey-as-local heuristic against future providers that might also set it.
  2. <img onError> fallback — broken-image previews leave Change/Remove only reachable via hidden hover controls.
  3. alt="" on preview — empty alt marks as decorative; alt={label} would be more useful to SR users.
  4. MIME filter case sensitivity — both validator regex and modal filter are case-sensitive; worth documenting/normalizing.
  5. Test gap — no coverage for non-"local" providers with a CDN URL, which would lock in the bug-fix invariant.

github run

@ascorbic ascorbic merged commit 9dfc65c into emdash-cms:main Apr 30, 2026
75 checks passed
Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@emdashbot emdashbot Bot mentioned this pull request Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants