Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/blockkit-media-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"emdash": minor
"@emdash-cms/admin": minor
"@emdash-cms/blocks": minor
---

Adds a `media_picker` Block Kit element: a thumbnail preview with a modal library picker and mime-type filter. Usable in plugin block forms and in Block Kit field widgets. 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. The `mime_type_filter` is restricted to image MIME types (`image/` or `image/<subtype>`); wildcards and non-image types are rejected.
13 changes: 13 additions & 0 deletions packages/admin/src/components/BlockKitFieldWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Input, Switch } from "@cloudflare/kumo";
import type { Element } from "@emdash-cms/blocks";
import * as React from "react";

import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField";

interface BlockKitFieldWidgetProps {
label: string;
elements: Element[];
Expand Down Expand Up @@ -113,6 +115,17 @@ function BlockKitFieldElement({
</div>
);
}
case "media_picker":
return (
<BlockKitMediaPickerField
actionId={element.action_id}
label={element.label}
placeholder={element.placeholder}
mimeTypeFilter={element.mime_type_filter}
value={value}
onChange={onChange}
/>
);
default:
return (
<div className="text-sm text-kumo-subtle">
Expand Down
122 changes: 122 additions & 0 deletions packages/admin/src/components/BlockKitMediaPickerField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Image as ImageIcon, X } from "@phosphor-icons/react";
import * as React from "react";

import type { MediaItem } from "../lib/api";
import { isSafeUrl } from "../lib/url";
import { MediaPickerModal } from "./MediaPickerModal";

export interface BlockKitMediaPickerFieldProps {
actionId: string;
label: string;
placeholder?: string;
mimeTypeFilter?: string;
value: unknown;
onChange: (actionId: string, value: unknown) => void;
}

/**
* Shared media_picker BlockKit element renderer used by `BlockKitFieldWidget`
* (sandboxed plugin field widgets) and the `BlockKitField` switch inside
* `PortableTextEditor` (plugin block forms).
*
* The stored value is the asset URL string, so values are interchangeable
* with `text_input`. Existing arbitrary URLs are tolerated but only previewed
* when they pass scheme/path safety checks.
*/
export function BlockKitMediaPickerField({
actionId,
label,
placeholder,
mimeTypeFilter,
value,
onChange,
}: BlockKitMediaPickerFieldProps) {
const { t } = useLingui();
const [pickerOpen, setPickerOpen] = React.useState(false);
const url = typeof value === "string" && value.length > 0 ? value : "";
const filter = mimeTypeFilter ?? "image/";
const canPreview = isSafePreviewUrl(url);

const handleSelect = (item: MediaItem) => {
// `MediaPickerModal` returns URL-inserted items with `id: ""` and no
// `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.

const localKey = item.storageKey || item.id;
const nextUrl = isLocalMedia && localKey ? `/_emdash/api/media/file/${localKey}` : item.url;
if (!nextUrl) return;
onChange(actionId, nextUrl);
};

return (
<div>
<label className="text-sm font-medium mb-1.5 block">{label}</label>
{canPreview ? (
<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.

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="absolute top-2 end-2 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex gap-1">
<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
{t`Change`}
</Button>
<Button
type="button"
shape="square"
variant="destructive"
className="h-8 w-8"
onClick={() => onChange(actionId, "")}
aria-label={t`Remove`}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="w-full h-24 border-dashed"
onClick={() => setPickerOpen(true)}
>
<div className="flex flex-col items-center gap-1.5 text-kumo-subtle">
<ImageIcon className="h-6 w-6" />
<span className="text-sm">{placeholder ?? t`Select media`}</span>
</div>
</Button>
)}
<MediaPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={handleSelect}
mimeTypeFilter={filter}
title={t`Select ${label}`}
/>
</div>
);
}

const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;

/**
* Returns true when `url` is safe to preview via `<img src={url}>`:
* - Same-origin relative path starting with `/` (but not `//`)
* - External `http://` or `https://` URL
*
* Rejects `javascript:`, `data:`, protocol-relative `//host`, and other
* schemes whose preview could leak credentials or trigger surprises.
*/
function isSafePreviewUrl(url: string): boolean {
if (!url) return false;
if (HAS_SCHEME_RE.test(url)) {
return isSafeUrl(url);
}
return url.startsWith("/") && !url.startsWith("//");
}
13 changes: 13 additions & 0 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import type { MediaItem } from "../lib/api";
import type { Section } from "../lib/api";
import { cn } from "../lib/utils";
import { CaretNext } from "./ArrowIcons.js";
import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField";
import { DragHandleWrapper } from "./editor/DragHandleWrapper";
import { ImageExtension } from "./editor/ImageNode";
import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension";
Expand Down Expand Up @@ -1231,6 +1232,18 @@ function BlockKitField({
<BlockKitRepeater field={field} pluginId={pluginId} value={value} onChange={onChange} />
);
}
case "media_picker": {
return (
<BlockKitMediaPickerField
actionId={field.action_id}
label={field.label}
placeholder={field.placeholder}
mimeTypeFilter={field.mime_type_filter}
value={value}
onChange={onChange}
/>
);
}
Comment thread
drudge marked this conversation as resolved.
default:
return <div className="text-sm text-kumo-subtle">Unknown field type: {field.type}</div>;
}
Expand Down
Loading
Loading