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:
5
.changeset/add-og-image-field.md
Normal file
5
.changeset/add-og-image-field.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@emdash-cms/admin": patch
|
||||
---
|
||||
|
||||
Add OG Image field to content editor
|
||||
@@ -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({
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(fields).map(([name, field]) => (
|
||||
<FieldRenderer
|
||||
key={name}
|
||||
name={name}
|
||||
field={field}
|
||||
value={formData[name]}
|
||||
onChange={handleFieldChange}
|
||||
onEditorReady={field.kind === "portableText" ? setPortableTextEditor : undefined}
|
||||
minimal={isDistractionFree}
|
||||
pluginBlocks={pluginBlocks}
|
||||
onBlockSidebarOpen={
|
||||
field.kind === "portableText" ? handleBlockSidebarOpen : undefined
|
||||
}
|
||||
onBlockSidebarClose={
|
||||
field.kind === "portableText" ? handleBlockSidebarClose : undefined
|
||||
}
|
||||
manifest={manifest}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(fields).map(([name, field]) => {
|
||||
const fieldEl = (
|
||||
<FieldRenderer
|
||||
key={name}
|
||||
name={name}
|
||||
field={field}
|
||||
value={formData[name]}
|
||||
onChange={handleFieldChange}
|
||||
onEditorReady={
|
||||
field.kind === "portableText" ? setPortableTextEditor : undefined
|
||||
}
|
||||
minimal={isDistractionFree}
|
||||
pluginBlocks={pluginBlocks}
|
||||
onBlockSidebarOpen={
|
||||
field.kind === "portableText" ? handleBlockSidebarOpen : undefined
|
||||
}
|
||||
onBlockSidebarClose={
|
||||
field.kind === "portableText" ? handleBlockSidebarClose : undefined
|
||||
}
|
||||
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>
|
||||
@@ -1138,6 +1160,11 @@ function FieldRenderer({
|
||||
return (
|
||||
<ImageFieldRenderer
|
||||
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}
|
||||
onChange={handleChange}
|
||||
required={field.required}
|
||||
@@ -1185,12 +1212,19 @@ interface ImageFieldValue {
|
||||
*/
|
||||
interface ImageFieldRendererProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: ImageFieldValue | string | undefined;
|
||||
onChange: (value: ImageFieldValue | undefined) => 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 && <p className="text-xs text-kumo-subtle mt-1">{description}</p>}
|
||||
{required && !displayUrl && (
|
||||
<p className="text-sm text-kumo-danger mt-1">This field is required</p>
|
||||
)}
|
||||
|
||||
84
packages/admin/src/components/SeoImageField.tsx
Normal file
84
packages/admin/src/components/SeoImageField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user