import { Badge, Button, Dialog, Input, InputArea, Label, Loader, Select, Switch, buttonVariants, } from "@cloudflare/kumo"; import { ArrowLeft, Check, Eye, Image as ImageIcon, MagnifyingGlass, X, Trash, ArrowsInSimple, ArrowsOutSimple, } from "@phosphor-icons/react"; import { Link } from "@tanstack/react-router"; import type { Editor } from "@tiptap/react"; import * as React from "react"; import type { BylineCreditInput, BylineSummary, ContentItem, MediaItem, UserListItem, TranslationSummary, } from "../lib/api"; import { getPreviewUrl, getDraftStatus } from "../lib/api"; import { usePluginAdmins } from "../lib/plugin-context.js"; import { cn, slugify } from "../lib/utils"; import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js"; import { DocumentOutline } from "./editor/DocumentOutline"; import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js"; /** Autosave debounce delay in milliseconds */ const AUTOSAVE_DELAY = 2000; function serializeEditorState(input: { data: Record; slug: string; bylines: BylineCreditInput[]; }) { return JSON.stringify({ data: input.data, slug: input.slug, bylines: input.bylines, }); } import type { ContentSeoInput } from "../lib/api"; import { ImageDetailPanel } from "./editor/ImageDetailPanel"; import type { ImageAttributes } from "./editor/ImageDetailPanel"; import { MediaPickerModal } from "./MediaPickerModal"; import { PortableTextEditor, type PluginBlockDef, type BlockSidebarPanel, } from "./PortableTextEditor"; import { RevisionHistory } from "./RevisionHistory"; import { SaveButton } from "./SaveButton"; import { SeoPanel } from "./SeoPanel"; import { TaxonomySidebar } from "./TaxonomySidebar"; // Editor role level (40) from @emdash-cms/auth const ROLE_EDITOR = 40; export interface FieldDescriptor { kind: string; label?: string; required?: boolean; options?: Array<{ value: string; label: string }>; widget?: string; } /** Simplified user info for current user context */ export interface CurrentUserInfo { id: string; role: number; } export interface ContentEditorProps { collection: string; collectionLabel: string; item?: ContentItem | null; fields: Record; isNew?: boolean; isSaving?: boolean; onSave?: (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; }) => void; /** Callback for autosave (debounced, skips revision creation) */ onAutosave?: (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; }) => void; /** Whether autosave is in progress */ isAutosaving?: boolean; /** Last autosave timestamp (for UI indicator) */ lastAutosaveAt?: Date | null; onPublish?: () => void; onUnpublish?: () => void; /** Callback to discard draft changes (revert to published version) */ onDiscardDraft?: () => void; /** Callback to schedule for future publishing */ onSchedule?: (scheduledAt: string) => void; /** Callback to cancel scheduling (revert to draft) */ onUnschedule?: () => void; /** Whether scheduling is in progress */ isScheduling?: boolean; /** Whether this collection supports drafts */ supportsDrafts?: boolean; /** Whether this collection supports revisions */ supportsRevisions?: boolean; /** Current user (for permission checks) */ currentUser?: CurrentUserInfo; /** Available users for author selection (only shown to editors+) */ users?: UserListItem[]; /** Callback when author is changed */ onAuthorChange?: (authorId: string | null) => void; /** Available byline profiles */ availableBylines?: BylineSummary[]; /** Selected byline credits (controlled for new entries) */ selectedBylines?: BylineCreditInput[]; /** Callback when byline credits are changed */ onBylinesChange?: (bylines: BylineCreditInput[]) => void; /** Callback for creating a byline inline from the editor */ onQuickCreateByline?: (input: { slug: string; displayName: string }) => Promise; /** Callback for updating a byline inline from the editor */ onQuickEditByline?: ( bylineId: string, input: { slug: string; displayName: string }, ) => Promise; /** Callback when item is deleted (moved to trash) */ onDelete?: () => void; /** Whether delete is in progress */ isDeleting?: boolean; /** i18n config — present when multiple locales are configured */ i18n?: { defaultLocale: string; locales: string[] }; /** Existing translations for this content item */ translations?: TranslationSummary[]; /** Callback to create a translation for a locale */ onTranslate?: (locale: string) => void; /** Plugin block types available for insertion in Portable Text fields */ pluginBlocks?: PluginBlockDef[]; /** Whether this collection has SEO fields enabled */ hasSeo?: boolean; /** Callback when SEO fields change */ onSeoChange?: (seo: ContentSeoInput) => void; /** Admin manifest for resolving plugin field widgets */ manifest?: import("../lib/api/client.js").AdminManifest | null; } /** Format scheduled date for display */ function formatScheduledDate(dateStr: string | null) { if (!dateStr) return null; const date = new Date(dateStr); return date.toLocaleString(); } /** * Content editor with dynamic field rendering */ export function ContentEditor({ collection, collectionLabel, item, fields, isNew, isSaving, onSave, onAutosave, isAutosaving, lastAutosaveAt, onPublish, onUnpublish, onDiscardDraft, onSchedule, onUnschedule, isScheduling, supportsDrafts = false, supportsRevisions = false, currentUser, users, onAuthorChange, availableBylines, selectedBylines, onBylinesChange, onQuickCreateByline, onQuickEditByline, onDelete, isDeleting, i18n, translations, onTranslate, pluginBlocks, hasSeo = false, onSeoChange, manifest, }: ContentEditorProps) { const [formData, setFormData] = React.useState>(item?.data || {}); const [slug, setSlug] = React.useState(item?.slug || ""); const [slugTouched, setSlugTouched] = React.useState(!!item?.slug); const [status, setStatus] = React.useState(item?.status || "draft"); const [internalBylines, setInternalBylines] = React.useState( item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ?? [], ); // Track portableText editor for document outline const [portableTextEditor, setPortableTextEditor] = React.useState(null); // Block sidebar state – when a block (e.g. image) requests sidebar space, this holds // the panel data. When non-null the sidebar shows the block panel instead of the // default content settings sections. const [blockSidebarPanel, setBlockSidebarPanel] = React.useState(null); const handleBlockSidebarOpen = React.useCallback((panel: BlockSidebarPanel) => { setBlockSidebarPanel(panel); }, []); const handleBlockSidebarClose = React.useCallback(() => { setBlockSidebarPanel((prev) => { prev?.onClose(); return null; }); }, []); // Track the last saved state to determine if dirty const [lastSavedData, setLastSavedData] = React.useState( serializeEditorState({ data: item?.data || {}, slug: item?.slug || "", bylines: item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel, })) ?? [], }), ); // Update form and last saved state when item changes (e.g., after save or restore) // Stringify the data for comparison since objects are compared by reference const itemDataString = React.useMemo(() => (item ? JSON.stringify(item.data) : ""), [item?.data]); React.useEffect(() => { if (item) { setFormData(item.data); setSlug(item.slug || ""); setSlugTouched(!!item.slug); setStatus(item.status); setInternalBylines( item.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ?? [], ); setLastSavedData( serializeEditorState({ data: item.data, slug: item.slug || "", bylines: item.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel, })) ?? [], }), ); } }, [item?.updatedAt, itemDataString, item?.slug, item?.status]); const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines; const handleBylinesChange = React.useCallback( (next: BylineCreditInput[]) => { if (isNew) { onBylinesChange?.(next); return; } setInternalBylines(next); onBylinesChange?.(next); }, [isNew, onBylinesChange], ); // Check if form has unsaved changes const currentData = React.useMemo( () => serializeEditorState({ data: formData, slug, bylines: activeBylines, }), [formData, slug, activeBylines], ); const isDirty = isNew || currentData !== lastSavedData; // Autosave with debounce // Track pending autosave to cancel on manual save const autosaveTimeoutRef = React.useRef | null>(null); const formDataRef = React.useRef(formData); formDataRef.current = formData; const slugRef = React.useRef(slug); slugRef.current = slug; React.useEffect(() => { // Don't autosave for new items (no ID yet) or if autosave isn't configured if (isNew || !onAutosave || !item?.id) { return; } // Don't autosave if not dirty or already saving if (!isDirty || isSaving || isAutosaving) { return; } // Clear any pending autosave if (autosaveTimeoutRef.current) { clearTimeout(autosaveTimeoutRef.current); } // Schedule autosave autosaveTimeoutRef.current = setTimeout(() => { onAutosave({ data: formDataRef.current, slug: slugRef.current || undefined, bylines: activeBylines, }); }, AUTOSAVE_DELAY); return () => { if (autosaveTimeoutRef.current) { clearTimeout(autosaveTimeoutRef.current); } }; }, [currentData, isNew, onAutosave, item?.id, isDirty, isSaving, isAutosaving, activeBylines]); // Cancel pending autosave on manual save const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // Cancel pending autosave if (autosaveTimeoutRef.current) { clearTimeout(autosaveTimeoutRef.current); autosaveTimeoutRef.current = null; } onSave?.({ data: formData, slug: slug || undefined, bylines: activeBylines, }); }; // Preview URL state const [isLoadingPreview, setIsLoadingPreview] = React.useState(false); const handlePreview = async () => { if (!item?.id) return; const contentUrl = (s: string) => { const pattern = manifest?.collections[collection]?.urlPattern; return pattern ? pattern.replace("{slug}", s) : `/${collection}/${s}`; }; setIsLoadingPreview(true); try { const result = await getPreviewUrl(collection, item.id); if (result?.url) { // Open preview in new tab window.open(result.url, "_blank", "noopener,noreferrer"); } else { // Fallback to direct URL if preview not configured window.open(contentUrl(slug || item.id), "_blank", "noopener,noreferrer"); } } catch { // Fallback to direct URL on error window.open(contentUrl(slug || item?.id || ""), "_blank", "noopener,noreferrer"); } finally { setIsLoadingPreview(false); } }; const handleFieldChange = React.useCallback( (name: string, value: unknown) => { setFormData((prev) => ({ ...prev, [name]: value })); if (name === "title" && !slugTouched && typeof value === "string" && value) { setSlug(slugify(value)); } }, [slugTouched], ); const handleSlugChange = (value: string) => { setSlug(value); setSlugTouched(true); }; const isPublished = status === "published"; // Draft revision status (only meaningful when supportsDrafts is on) const draftStatus = item ? getDraftStatus(item) : "unpublished"; const hasPendingChanges = draftStatus === "published_with_changes"; const isLive = draftStatus === "published" || draftStatus === "published_with_changes"; // Scheduling — keyed off scheduledAt rather than status, since published // posts can now have a pending schedule without changing status. const hasSchedule = Boolean(item?.scheduledAt); const canSchedule = !isNew && !hasSchedule && Boolean(onSchedule) && (!isPublished || hasPendingChanges); // Schedule datetime state const [scheduleDate, setScheduleDate] = React.useState(""); const [showScheduler, setShowScheduler] = React.useState(false); // Distraction-free mode state const [isDistractionFree, setIsDistractionFree] = React.useState(false); // Escape exits distraction-free mode React.useEffect(() => { if (!isDistractionFree) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setIsDistractionFree(false); } }; document.addEventListener("keydown", handleKeyDown, { capture: true }); return () => document.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [isDistractionFree]); const handleScheduleSubmit = () => { if (scheduleDate && onSchedule) { // Convert local datetime to ISO string const date = new Date(scheduleDate); onSchedule(date.toISOString()); setShowScheduler(false); setScheduleDate(""); } }; return (
{/* Header - show on hover in distraction-free mode */}
{!isDistractionFree && (
{/* Autosave indicator */} {!isNew && onAutosave && (
{isAutosaving ? ( <> Saving... ) : lastAutosaveAt ? ( <>
)} {!isDistractionFree && ( )} {!isNew && ( )} {!isNew && ( <> {supportsDrafts && hasPendingChanges && onDiscardDraft && ( ( )} /> Discard draft changes? This will revert to the published version. Your draft changes will be lost.
( )} /> ( )} />
)} {isLive ? ( <> {hasPendingChanges ? ( ) : ( )} ) : ( )} )}
{/* Main content area */}
{/* Editor fields */}
{Object.entries(fields).map(([name, field]) => ( ))}
{/* Sidebar - hidden in distraction-free mode */}
{blockSidebarPanel ? ( /* Block sidebar panel – replaces default sections when a block requests it */ blockSidebarPanel.type === "image" ? ( blockSidebarPanel.onUpdate(attrs as unknown as Record) } onReplace={(attrs) => blockSidebarPanel.onReplace(attrs as unknown as Record) } onDelete={() => { blockSidebarPanel.onDelete(); setBlockSidebarPanel(null); }} onClose={handleBlockSidebarClose} inline /> ) : null ) : ( /* Default content settings sections – single card with dividers */
{/* Publish settings */}

Publish

handleSlugChange(e.target.value)} placeholder="my-post-slug" />
{supportsDrafts ? ( <> {isLive && Published} {hasPendingChanges && Pending changes} {!isLive && !hasSchedule && Draft} {hasSchedule && Scheduled} ) : ( {status.charAt(0).toUpperCase() + status.slice(1)} )}
{item?.scheduledAt && (

Scheduled for: {formatScheduledDate(item.scheduledAt)}

)}
{canSchedule && (
{showScheduler ? (
setScheduleDate(e.target.value)} min={new Date().toISOString().slice(0, 16)} />
) : ( )}
)} {item && (

Created: {new Date(item.createdAt).toLocaleString()}

Updated: {new Date(item.updatedAt).toLocaleString()}

)} {!isNew && onDelete && (
( )} /> Move to Trash? This will move the item to trash. You can restore it later from the trash.
( )} /> ( )} />
)}
{/* Ownership selector - shown only to editors and above */} {currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && (

Ownership

)} {/* Byline credits */} {currentUser && currentUser.role >= ROLE_EDITOR && (

Bylines

)} {/* Translations sidebar - shown when i18n is enabled */} {i18n && item && !isNew && (

Translations

{i18n.locales.map((locale) => { const translation = translations?.find((t) => t.locale === locale); const isCurrent = locale === item.locale; return (
{locale} {locale === i18n.defaultLocale && ( (default) )} {isCurrent && ( current )}
{translation && !isCurrent ? ( Edit ) : !translation && onTranslate ? ( ) : null}
); })}
)} {/* Taxonomy selector */} {item && (
)} {/* SEO panel - shown for collections with hasSeo enabled */} {hasSeo && !isNew && onSeoChange && (

SEO

)} {/* Document outline - shown when editing content with portableText */} {portableTextEditor && (
)} {/* Revision history - shown for existing items in collections that support it */} {!isNew && item && supportsRevisions && (
)}
)}
); } interface FieldRendererProps { name: string; field: FieldDescriptor; value: unknown; onChange: (name: string, value: unknown) => void; /** Callback when a portableText editor is ready */ onEditorReady?: (editor: Editor) => void; /** Minimal chrome - hides toolbar, fades labels, removes borders (distraction-free mode) */ minimal?: boolean; /** Plugin block types available for insertion in Portable Text fields */ pluginBlocks?: PluginBlockDef[]; /** Callback when a block node requests sidebar space */ onBlockSidebarOpen?: (panel: BlockSidebarPanel) => void; /** Callback when a block node closes its sidebar */ onBlockSidebarClose?: () => void; /** Admin manifest for resolving sandboxed field widget elements */ manifest?: import("../lib/api/client.js").AdminManifest | null; } /** * Render field based on type */ function FieldRenderer({ name, field, value, onChange, onEditorReady, minimal, pluginBlocks, onBlockSidebarOpen, onBlockSidebarClose, manifest, }: FieldRendererProps) { const pluginAdmins = usePluginAdmins(); const label = field.label || name.charAt(0).toUpperCase() + name.slice(1); const id = `field-${name}`; const labelClass = minimal ? "text-kumo-subtle/50 text-xs font-normal" : undefined; const handleChange = React.useCallback((v: unknown) => onChange(name, v), [onChange, name]); // Check for plugin field widget override if (field.widget) { const sepIdx = field.widget.indexOf(":"); if (sepIdx <= 0) { console.warn( `[emdash] Field "${name}" has widget "${field.widget}" but it should use the format "pluginId:widgetName". Falling back to default editor.`, ); } if (sepIdx > 0) { const pluginId = field.widget.slice(0, sepIdx); const widgetName = field.widget.slice(sepIdx + 1); // Trusted plugin: React component const PluginField = pluginAdmins[pluginId]?.fields?.[widgetName] as | React.ComponentType<{ value: unknown; onChange: (value: unknown) => void; label: string; id: string; required?: boolean; options?: Array<{ value: string; label: string }>; minimal?: boolean; }> | undefined; if (typeof PluginField === "function") { return ( ); } // Sandboxed plugin: Block Kit elements from manifest if (manifest) { const pluginManifest = manifest.plugins[pluginId]; const widgetDef = pluginManifest?.fieldWidgets?.find((w) => w.name === widgetName); if (widgetDef?.elements && widgetDef.elements.length > 0) { return ( ); } } // Widget declared but plugin not found/active -- fall through to default } } switch (field.kind) { case "string": return ( {label}} id={id} value={typeof value === "string" ? value : ""} onChange={(e) => handleChange(e.target.value)} required={field.required} className={ minimal ? "border-0 bg-transparent px-0 text-lg font-medium focus-visible:ring-0 focus-visible:ring-offset-0" : undefined } /> ); case "number": return ( {label}} id={id} type="number" value={typeof value === "number" ? value : ""} onChange={(e) => handleChange(Number(e.target.value))} required={field.required} /> ); case "boolean": return ( ); case "portableText": { const labelId = `${id}-label`; return (
{!minimal && ( {label} )}
); } case "richText": // For richText (markdown), use InputArea return ( handleChange(e.target.value)} rows={10} placeholder="Enter markdown content..." /> ); case "select": { const selectItems: Record = {}; for (const opt of field.options ?? []) { selectItems[opt.value] = opt.label; } return ( ); } case "datetime": return ( handleChange(e.target.value)} required={field.required} /> ); case "image": { // value is either an ImageFieldValue object, a legacy string URL, or undefined const imageValue = value != null && typeof value === "object" ? (value as ImageFieldValue) : undefined; return ( ); } default: // Default to text input return ( handleChange(e.target.value)} required={field.required} /> ); } } /** * Image field value - matches emdash's MediaValue type */ interface ImageFieldValue { id: string; /** Provider ID (e.g., "local", "cloudflare-images") */ provider?: string; /** Direct URL for local media or legacy data */ src?: string; /** Preview URL for admin display (separate from src used for rendering) */ previewUrl?: string; alt?: string; width?: number; height?: number; /** Provider-specific metadata */ meta?: Record; } /** * Image field with media picker * * Stores full image metadata including dimensions for responsive images. * Handles backwards compatibility with legacy string URLs. */ interface ImageFieldRendererProps { label: string; value: ImageFieldValue | string | undefined; onChange: (value: ImageFieldValue | undefined) => void; required?: boolean; } function ImageFieldRenderer({ label, 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 const displayUrl = typeof value === "string" ? value : value?.previewUrl || value?.src || (value && (!value.provider || value.provider === "local") ? `/_emdash/api/media/file/${typeof value.meta?.storageKey === "string" ? value.meta.storageKey : value.id}` : undefined); const handleSelect = (item: MediaItem) => { const isLocalProvider = !item.provider || item.provider === "local"; onChange({ id: item.id, provider: item.provider || "local", // Local media derives URLs from meta.storageKey at display time — no src needed // External providers cache a preview URL for admin display previewUrl: isLocalProvider ? undefined : item.url, alt: item.alt || "", width: item.width, height: item.height, meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta, }); }; const handleRemove = () => { onChange(undefined); }; return (
{displayUrl ? (
) : ( )} {required && !displayUrl && (

This field is required

)}
); } /** * Author selector component for editors and above */ interface AuthorSelectorProps { authorId: string | null; users: UserListItem[]; onChange?: (authorId: string | null) => void; } interface BylineCreditsEditorProps { credits: BylineCreditInput[]; bylines: BylineSummary[]; onChange: (bylines: BylineCreditInput[]) => void; onQuickCreate?: (input: { slug: string; displayName: string }) => Promise; onQuickEdit?: ( bylineId: string, input: { slug: string; displayName: string }, ) => Promise; } function BylineCreditsEditor({ credits, bylines, onChange, onQuickCreate, onQuickEdit, }: BylineCreditsEditorProps) { const [selectedBylineId, setSelectedBylineId] = React.useState(""); const [quickName, setQuickName] = React.useState(""); const [quickSlug, setQuickSlug] = React.useState(""); const [quickError, setQuickError] = React.useState(null); const [isCreating, setIsCreating] = React.useState(false); const [editBylineId, setEditBylineId] = React.useState(null); const [editName, setEditName] = React.useState(""); const [editSlug, setEditSlug] = React.useState(""); const [editError, setEditError] = React.useState(null); const [isEditing, setIsEditing] = React.useState(false); const bylineMap = React.useMemo(() => new Map(bylines.map((b) => [b.id, b])), [bylines]); const availableToAdd = bylines.filter((b) => !credits.some((c) => c.bylineId === b.id)); const move = (index: number, direction: -1 | 1) => { const target = index + direction; if (target < 0 || target >= credits.length) return; const next = [...credits]; const [moved] = next.splice(index, 1); if (!moved) return; next.splice(target, 0, moved); onChange(next); }; const resetQuickCreate = () => { setQuickName(""); setQuickSlug(""); setQuickError(null); }; const openEditByline = (byline: BylineSummary) => { setEditBylineId(byline.id); setEditName(byline.displayName); setEditSlug(byline.slug); setEditError(null); }; const resetQuickEdit = () => { setEditBylineId(null); setEditName(""); setEditSlug(""); setEditError(null); }; return (
{credits.length > 0 ? (
{credits.map((credit, index) => { const byline = bylineMap.get(credit.bylineId); if (!byline) return null; return (

{byline.displayName}

{byline.slug}

{onQuickEdit && ( )}
{ const next = [...credits]; const current = next[index]; if (!current) return; next[index] = { ...current, roleLabel: e.target.value || null, }; onChange(next); }} />
); })}
) : (

No bylines selected.

)} {onQuickCreate && ( ( )} /> Create byline
{ setQuickName(e.target.value); if (!quickSlug) setQuickSlug(slugify(e.target.value)); }} /> setQuickSlug(e.target.value)} /> {quickError &&

{quickError}

}
( )} />
)} {onQuickEdit && editBylineId && ( (!open ? resetQuickEdit() : undefined)}> Edit byline
{ setEditName(e.target.value); if (!editSlug) setEditSlug(slugify(e.target.value)); }} /> setEditSlug(e.target.value)} /> {editError &&

{editError}

}
)}
); } function AuthorSelector({ authorId, users, onChange }: AuthorSelectorProps) { const currentAuthor = users.find((u) => u.id === authorId); const authorItems: Record = { unassigned: "Unassigned" }; for (const user of users) { authorItems[user.id] = user.name || user.email; } return (
{currentAuthor &&

{currentAuthor.email}

}
); }