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";
|
} 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,25 +614,46 @@ 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]) => {
|
||||||
<FieldRenderer
|
const fieldEl = (
|
||||||
key={name}
|
<FieldRenderer
|
||||||
name={name}
|
key={name}
|
||||||
field={field}
|
name={name}
|
||||||
value={formData[name]}
|
field={field}
|
||||||
onChange={handleFieldChange}
|
value={formData[name]}
|
||||||
onEditorReady={field.kind === "portableText" ? setPortableTextEditor : undefined}
|
onChange={handleFieldChange}
|
||||||
minimal={isDistractionFree}
|
onEditorReady={
|
||||||
pluginBlocks={pluginBlocks}
|
field.kind === "portableText" ? setPortableTextEditor : undefined
|
||||||
onBlockSidebarOpen={
|
}
|
||||||
field.kind === "portableText" ? handleBlockSidebarOpen : undefined
|
minimal={isDistractionFree}
|
||||||
}
|
pluginBlocks={pluginBlocks}
|
||||||
onBlockSidebarClose={
|
onBlockSidebarOpen={
|
||||||
field.kind === "portableText" ? handleBlockSidebarClose : undefined
|
field.kind === "portableText" ? handleBlockSidebarOpen : undefined
|
||||||
}
|
}
|
||||||
manifest={manifest}
|
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>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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