-
Notifications
You must be signed in to change notification settings - Fork 974
feat(admin): add media_picker BlockKit element #731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
27cae95
a79a7b5
14410e6
59f884d
6091cd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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; | ||
| 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="" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| className="max-h-40 w-full rounded-md border border-kumo-line object-contain bg-kumo-muted" | ||
| referrerPolicy="no-referrer" | ||
| loading="lazy" | ||
| /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| <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("//"); | ||
| } | ||
There was a problem hiding this comment.
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
storageKeyis only ever set on local items. TheMediaItemJSDoc backs that up today ("Storage key for local media... Not present for external URLs."), but if a future provider ever populatesstorageKeyit 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:
Up to you — happy to leave as-is given the documented contract.