Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/calm-cats-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Add a trusted plugin extension point for contributing isolated, host-framed panels to the saved content editor settings sidebar. Panels support collection and role filtering, deterministic ordering, manifest lifecycle checks, and render-error recovery.
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,46 @@ export function SEOWidget() {

Widgets wrap automatically based on screen width.

## Content editor panels

A trusted React plugin can add host-framed sections to the settings sidebar for saved content entries. EmDash owns the section heading and placement, applies collection and role filters, and isolates rendering failures so one plugin panel cannot unmount the editor.

Export a `contentEditorPanels` array from the plugin admin entry:

```tsx title="src/admin.tsx"
import type { ContentEditorPanelContext } from "@emdash-cms/admin";

function ContentInsights({ entry, locale }: ContentEditorPanelContext) {
return (
<p>
Analysis for {entry.slug} in {locale ?? "the default locale"}
</p>
);
}

export const contentEditorPanels = [
{
id: "content-insights",
title: "Content insights",
component: ContentInsights,
collections: ["posts", "pages"],
minRole: 40,
order: 10,
},
];
```

Each panel receives the saved `entry`, its `collection`, and resolved `locale`. Panels are not mounted for new, unsaved entries. `collections` may be an array or a predicate and can be omitted to support every collection. Lower `order` values render first among contributed panels; ties are resolved deterministically by plugin and panel ID.

Panel IDs must be unique within the plugin. Keep panel content responsive to the narrow settings sidebar and perform authorization in plugin API routes rather than relying on `minRole`, which only controls visibility.

<Aside type="note">
This extension point is available only to trusted React plugins. Sandboxed plugins cannot execute React components inside the host admin.
</Aside>

## Export structure

The admin entry point exports two objects:
The admin entry point exports the objects used by the features it implements:

```typescript title="src/admin.tsx"
import { SettingsPage } from "./components/SettingsPage";
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,7 @@ export function ContentEditor({
collection={collection}
item={item}
isNew={isNew}
manifest={manifest}
entryLocale={entryLocale}
slug={slug}
onSlugChange={handleSlugChange}
Expand Down
50 changes: 50 additions & 0 deletions packages/admin/src/components/ContentSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Editor } from "@tiptap/react";
import * as React from "react";

import type {
AdminManifest,
BylineCreditInput,
BylineSummary,
ContentItem,
Expand All @@ -25,8 +26,13 @@ import type {
UserListItem,
} from "../lib/api";
import { fetchBylines } from "../lib/api";
import {
ContentEditorPanelBoundary,
resolveContentEditorPanels,
} from "../lib/content-editor-panels";
import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js";
import { useDebouncedValue } from "../lib/hooks.js";
import { usePluginAdmins } from "../lib/plugin-context";
import { cn, slugify } from "../lib/utils";
import type { CurrentUserInfo } from "./ContentEditor.js";
import { ContentStatusBadge, isContentStatusState } from "./ContentStatusBadge.js";
Expand Down Expand Up @@ -290,6 +296,7 @@ export interface ContentSettingsPanelProps {
collection: string;
item?: ContentItem | null;
isNew?: boolean;
manifest?: AdminManifest | null;
/** Locale this entry is bound to (URL `?locale=` for new entries). */
entryLocale?: string | null;
slug: string;
Expand Down Expand Up @@ -346,6 +353,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
collection,
item,
isNew,
manifest,
entryLocale,
slug,
onSlugChange,
Expand Down Expand Up @@ -385,6 +393,19 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
}: ContentSettingsPanelProps) {
const { t } = useLingui();
const navigate = useNavigate();
const pluginAdmins = usePluginAdmins();
const extensionPanels = React.useMemo(
() =>
!isNew && item
? resolveContentEditorPanels(
pluginAdmins,
collection,
currentUser?.role ?? 0,
manifest?.plugins,
)
: [],
[collection, currentUser?.role, isNew, item, manifest?.plugins, pluginAdmins],
);

const [scheduleDate, setScheduleDate] = React.useState<string>("");
const [showScheduler, setShowScheduler] = React.useState(false);
Expand Down Expand Up @@ -680,6 +701,35 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
</SortableContentSettingsSection>
)}

{item &&
extensionPanels.map(({ pluginId, extension }) => {
const Panel = extension.component;
const sectionId = `plugin:${pluginId}:${extension.id}`;

return (
<SortableContentSettingsSection
key={sectionId}
id={sectionId}
label={extension.title}
>
<div className="min-w-0 p-4">
<Text bold as="h3" DANGEROUS_className="mb-4">
{extension.title}
</Text>
<ContentEditorPanelBoundary pluginId={pluginId} panelId={extension.id}>
<div className="min-w-0 max-w-full">
<Panel
collection={collection}
entry={item}
locale={item.locale ?? entryLocale ?? undefined}
/>
</div>
</ContentEditorPanelBoundary>
</div>
</SortableContentSettingsSection>
);
})}

{portableTextEditor && (
<SortableContentSettingsSection id="outline" label={t`Outline`} disclosure>
<div className="p-4">
Expand Down
16 changes: 10 additions & 6 deletions packages/admin/src/components/SortableContentSettingsSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,18 @@ export function SortableContentSettingsSections({
setStoredLayout(readStoredLayout(storageKey));
}, [storageKey]);

const layout = React.useMemo(() => resolveContentSettingsLayout(storedLayout), [storedLayout]);
const sectionsById = React.useMemo(() => {
const sections = React.Children.toArray(children).filter(
(child): child is React.ReactElement<SortableContentSettingsSectionProps> =>
React.isValidElement<SortableContentSettingsSectionProps>(child),
);
return new Map(sections.map((section) => [section.props.id, section]));
}, [children]);
const sectionIds = React.useMemo(() => [...sectionsById.keys()], [sectionsById]);
const layout = React.useMemo(
() => resolveContentSettingsLayout(storedLayout, sectionIds),
[sectionIds, storedLayout],
);
const visibleIds = React.useMemo(
() => layout.order.filter((id) => sectionsById.has(id)),
[layout.order, sectionsById],
Expand All @@ -108,7 +112,7 @@ export function SortableContentSettingsSections({
);
const handleDragStart = React.useCallback(
(event: DragStartEvent) => {
setActiveId(String(event.active.id) as ContentSettingsSectionId);
setActiveId(String(event.active.id));
onSortingChange?.(true);
},
[onSortingChange],
Expand All @@ -123,11 +127,11 @@ export function SortableContentSettingsSections({
setActiveId(null);
onSortingChange?.(false);
if (event.over && event.active.id !== event.over.id) {
const movedId = String(event.active.id) as ContentSettingsSectionId;
const overId = String(event.over.id) as ContentSettingsSectionId;
const movedId = String(event.active.id);
const overId = String(event.over.id);
setStoredLayout((current) => {
const next = reorderContentSettingsLayout(
resolveContentSettingsLayout(current),
resolveContentSettingsLayout(current, sectionIds),
movedId,
overId,
);
Expand All @@ -136,7 +140,7 @@ export function SortableContentSettingsSections({
});
}
},
[onSortingChange, storageKey],
[onSortingChange, sectionIds, storageKey],
);

return (
Expand Down
5 changes: 5 additions & 0 deletions packages/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export * from "./lib/api";
// Utilities
export { cn } from "./lib/utils";

export {
type ContentEditorPanelContext,
type ContentEditorPanelExtension,
} from "./lib/content-editor-panels";

// Plugin admin context (for accessing plugin components)
export {
PluginAdminProvider,
Expand Down
Loading
Loading