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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* style: format

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
This commit is contained in:
Joost de Valk
2026-04-08 00:14:54 +02:00
committed by GitHub
parent d6cfc437f2
commit 12d73ff456
3 changed files with 144 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---
Add OG Image field to content editor

View File

@@ -66,6 +66,7 @@ import {
} from "./PortableTextEditor"; } from "./PortableTextEditor";
import { RevisionHistory } from "./RevisionHistory"; import { RevisionHistory } from "./RevisionHistory";
import { SaveButton } from "./SaveButton"; import { SaveButton } from "./SaveButton";
import { SeoImageField } from "./SeoImageField";
import { SeoPanel } from "./SeoPanel"; import { SeoPanel } from "./SeoPanel";
import { TaxonomySidebar } from "./TaxonomySidebar"; import { TaxonomySidebar } from "./TaxonomySidebar";
@@ -613,14 +614,17 @@ export function ContentEditor({
)} )}
> >
<div className="space-y-4"> <div className="space-y-4">
{Object.entries(fields).map(([name, field]) => ( {Object.entries(fields).map(([name, field]) => {
const fieldEl = (
<FieldRenderer <FieldRenderer
key={name} key={name}
name={name} name={name}
field={field} field={field}
value={formData[name]} value={formData[name]}
onChange={handleFieldChange} onChange={handleFieldChange}
onEditorReady={field.kind === "portableText" ? setPortableTextEditor : undefined} onEditorReady={
field.kind === "portableText" ? setPortableTextEditor : undefined
}
minimal={isDistractionFree} minimal={isDistractionFree}
pluginBlocks={pluginBlocks} pluginBlocks={pluginBlocks}
onBlockSidebarOpen={ onBlockSidebarOpen={
@@ -631,7 +635,25 @@ export function ContentEditor({
} }
manifest={manifest} manifest={manifest}
/> />
))} );
if (
name === "featured_image" &&
field.kind === "image" &&
hasSeo &&
!isNew &&
onSeoChange
) {
return (
<div key={`${name}-with-seo`} className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>{fieldEl}</div>
<div>
<SeoImageField seo={item?.seo} onChange={onSeoChange} />
</div>
</div>
);
}
return fieldEl;
})}
</div> </div>
</div> </div>
</div> </div>
@@ -1138,6 +1160,11 @@ function FieldRenderer({
return ( return (
<ImageFieldRenderer <ImageFieldRenderer
label={label} label={label}
description={
name === "featured_image"
? "Used as the main visual for this post on listing pages and at the top of the post"
: undefined
}
value={imageValue} value={imageValue}
onChange={handleChange} onChange={handleChange}
required={field.required} required={field.required}
@@ -1185,12 +1212,19 @@ interface ImageFieldValue {
*/ */
interface ImageFieldRendererProps { interface ImageFieldRendererProps {
label: string; label: string;
description?: string;
value: ImageFieldValue | string | undefined; value: ImageFieldValue | string | undefined;
onChange: (value: ImageFieldValue | undefined) => void; onChange: (value: ImageFieldValue | undefined) => void;
required?: boolean; required?: boolean;
} }
function ImageFieldRenderer({ label, value, onChange, required }: ImageFieldRendererProps) { function ImageFieldRenderer({
label,
description,
value,
onChange,
required,
}: ImageFieldRendererProps) {
const [pickerOpen, setPickerOpen] = React.useState(false); const [pickerOpen, setPickerOpen] = React.useState(false);
// Normalize value to get display URL (handles both object and legacy string) // 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 // 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/" mimeTypeFilter="image/"
title={`Select ${label}`} title={`Select ${label}`}
/> />
{description && <p className="text-xs text-kumo-subtle mt-1">{description}</p>}
{required && !displayUrl && ( {required && !displayUrl && (
<p className="text-sm text-kumo-danger mt-1">This field is required</p> <p className="text-sm text-kumo-danger mt-1">This field is required</p>
)} )}

View File

@@ -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 (
<div>
<Label>OG Image</Label>
{imageUrl ? (
<div className="mt-2 relative group">
<img src={imageUrl} alt="" className="max-h-48 rounded-lg border object-cover" />
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
Change
</Button>
<Button
type="button"
shape="square"
variant="destructive"
className="h-8 w-8"
onClick={handleRemove}
aria-label="Remove image"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="mt-2 w-full h-32 border-dashed"
onClick={() => setPickerOpen(true)}
>
<div className="flex flex-col items-center gap-2 text-kumo-subtle">
<ImageIcon className="h-8 w-8" />
<span>Select OG image</span>
</div>
</Button>
)}
<p className="text-xs text-kumo-subtle mt-1">
Image shown when this page is shared on social media
</p>
<MediaPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={handleSelect}
mimeTypeFilter="image/"
title="Select OG Image"
/>
</div>
);
}