From 12d73ff4560551bbe873783e4628bbd80809c449 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Wed, 8 Apr 2026 00:14:54 +0200 Subject: [PATCH] Add OG Image field to content editor (#328) * Add OG Image field to content editor The `_emdash_seo` table and content API already support `seo_image`, but the admin UI had no way to set it. This adds: - `SeoImageField` component using the existing `MediaPickerModal` - 2-column grid layout placing the OG Image next to the Featured Image - `description` prop on `ImageFieldRenderer` for helper text below images - Preserve `seo.image` in `SeoPanel.emitChange` so sidebar edits don't clear the image Closes #327 Co-Authored-By: Claude Opus 4.6 (1M context) * style: format * Add changeset and fix stale seo.image in SeoPanel - Add changeset for @emdash-cms/admin patch release - Remove image from SeoPanel.emitChange to avoid overwriting a freshly-selected OG image with a stale prop value Co-Authored-By: Claude Opus 4.6 (1M context) * Address review feedback on OG Image field - Fix #1: Use @phosphor-icons/react instead of lucide-react - Fix #2: Send minimal patch { image } instead of spreading stale seo props - Fix #3: Only show OG Image next to the featured_image field, not all image fields - Fix #4: Only add description text for the featured_image field - Fix #5: Add responsive breakpoint (grid-cols-1 md:grid-cols-2) Co-Authored-By: Claude Opus 4.6 (1M context) * style: format --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: emdashbot[bot] Co-authored-by: Matt Kane --- .changeset/add-og-image-field.md | 5 ++ .../admin/src/components/ContentEditor.tsx | 75 ++++++++++++----- .../admin/src/components/SeoImageField.tsx | 84 +++++++++++++++++++ 3 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 .changeset/add-og-image-field.md create mode 100644 packages/admin/src/components/SeoImageField.tsx diff --git a/.changeset/add-og-image-field.md b/.changeset/add-og-image-field.md new file mode 100644 index 0000000..003e2a9 --- /dev/null +++ b/.changeset/add-og-image-field.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Add OG Image field to content editor diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 42e5ab7..1122e0a 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -66,6 +66,7 @@ import { } from "./PortableTextEditor"; import { RevisionHistory } from "./RevisionHistory"; import { SaveButton } from "./SaveButton"; +import { SeoImageField } from "./SeoImageField"; import { SeoPanel } from "./SeoPanel"; import { TaxonomySidebar } from "./TaxonomySidebar"; @@ -613,25 +614,46 @@ export function ContentEditor({ )} >
- {Object.entries(fields).map(([name, field]) => ( - - ))} + {Object.entries(fields).map(([name, field]) => { + const fieldEl = ( + + ); + if ( + name === "featured_image" && + field.kind === "image" && + hasSeo && + !isNew && + onSeoChange + ) { + return ( +
+
{fieldEl}
+
+ +
+
+ ); + } + return fieldEl; + })}
@@ -1138,6 +1160,11 @@ function FieldRenderer({ return ( void; required?: boolean; } -function ImageFieldRenderer({ label, value, onChange, required }: ImageFieldRendererProps) { +function ImageFieldRenderer({ + label, + description, + value, + onChange, + required, +}: ImageFieldRendererProps) { const [pickerOpen, setPickerOpen] = React.useState(false); // Normalize value to get display URL (handles both object and legacy string) // Prefer previewUrl for admin display, fall back to src, then derive from storageKey/id @@ -1265,6 +1299,7 @@ function ImageFieldRenderer({ label, value, onChange, required }: ImageFieldRend mimeTypeFilter="image/" title={`Select ${label}`} /> + {description &&

{description}

} {required && !displayUrl && (

This field is required

)} diff --git a/packages/admin/src/components/SeoImageField.tsx b/packages/admin/src/components/SeoImageField.tsx new file mode 100644 index 0000000..69f5609 --- /dev/null +++ b/packages/admin/src/components/SeoImageField.tsx @@ -0,0 +1,84 @@ +/** + * SEO OG Image field for the content editor. + * + * Renders an image picker (reusing MediaPickerModal) that stores the + * selected image URL in `seo.image`. Designed to sit next to the + * Featured Image field in a two-column grid. + */ + +import { Button, Label } from "@cloudflare/kumo"; +import { Image as ImageIcon, X } from "@phosphor-icons/react"; +import * as React from "react"; + +import type { ContentSeo, ContentSeoInput, MediaItem } from "../lib/api"; +import { MediaPickerModal } from "./MediaPickerModal"; + +export interface SeoImageFieldProps { + seo?: ContentSeo; + onChange: (seo: ContentSeoInput) => void; +} + +export function SeoImageField({ seo, onChange }: SeoImageFieldProps) { + const [pickerOpen, setPickerOpen] = React.useState(false); + const imageUrl = seo?.image || null; + + const handleSelect = (item: MediaItem) => { + const isLocalProvider = !item.provider || item.provider === "local"; + const url = isLocalProvider + ? `/_emdash/api/media/file/${item.storageKey || item.id}` + : item.url; + onChange({ image: url }); + }; + + const handleRemove = () => { + onChange({ image: null }); + }; + + return ( +
+ + {imageUrl ? ( +
+ +
+ + +
+
+ ) : ( + + )} +

+ Image shown when this page is shared on social media +

+ +
+ ); +}