import { Badge, Button, Checkbox, Dialog, Input, InputArea, Label, LinkButton, Loader, Select, Switch, } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Check, Eye, MagnifyingGlass, Paperclip, X, Trash, ArrowsInSimple, ArrowsOutSimple, ArrowSquareOut, } from "@phosphor-icons/react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useNavigate } 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 { fetchBylines, getPreviewUrl, getDraftStatus } from "../lib/api"; import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; import { useDebouncedValue } from "../lib/hooks.js"; import { formatFileSize, getFileIcon } from "../lib/media-utils"; import { usePluginAdmins } from "../lib/plugin-context.js"; import { contentUrl, isSafeUrl } from "../lib/url.js"; import { cn, slugify } from "../lib/utils"; import { ArrowPrev } from "./ArrowIcons.js"; import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js"; import { DocumentOutline } from "./editor/DocumentOutline"; import { ImageFieldRenderer, type ImageFieldValue } from "./ImageFieldRenderer.js"; import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js"; import { RepeaterField } from "./RepeaterField.js"; import { RouterLinkButton } from "./RouterLinkButton.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"; import { TranslationsPanel } from "./TranslationsPanel.js"; // Editor role level (40) from @emdash-cms/auth const ROLE_EDITOR = 40; export interface FieldDescriptor { id?: string; kind: string; label?: string; required?: boolean; /** * For `select` / `multiSelect`: the list of enum choices. * For `json` fields driven by a plugin `widget`: arbitrary widget config. */ options?: Array<{ value: string; label: string }> | Record; widget?: string; validation?: Record; } /** 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; /** * Locale this entry is bound to. For existing entries this matches * `item.locale`; for new entries it's the URL `?locale=` (or default). * Threaded into the byline picker so the empty-state CTA links to the * right locale on the Bylines manager. */ entryLocale?: string | null; 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; /** Whether this collection supports preview */ supportsPreview?: 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[]; /** Whether the parent's byline picker query has resolved. Suppresses the empty-state flash before first fetch. */ availableBylinesLoaded?: boolean; /** 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, entryLocale, isSaving, onSave, onAutosave, isAutosaving, lastAutosaveAt, onPublish, onUnpublish, onDiscardDraft, onSchedule, onUnschedule, isScheduling, supportsDrafts = false, supportsRevisions = false, supportsPreview = false, currentUser, users, onAuthorChange, availableBylines, availableBylinesLoaded, selectedBylines, onBylinesChange, onQuickCreateByline, onQuickEditByline, onDelete, isDeleting, i18n, translations, onTranslate, pluginBlocks, hasSeo = false, onSeoChange, manifest, }: ContentEditorProps) { const { t } = useLingui(); const navigate = useNavigate(); 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 })) ?? [], ); // Gates whether `bylines` is included in the save payload. Untouched // edits must not ship `[]` — strict per-locale hydration can return // empty for entries with credits at other locales, and sending `[]` // would wipe them. const [bylinesTouched, setBylinesTouched] = React.useState(false); // Track portableText editor for document outline. Only the "content" // field wires its editor into this slot (see onEditorReady below). 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; }); }, []); const handleSeoChange = React.useCallback( (seo: ContentSeoInput) => { onSeoChange?.(seo); }, [onSeoChange], ); // 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, })) ?? [], }), ); const pendingAutosaveStateRef = React.useRef(null); // Synchronously reset form state when the underlying item changes (e.g. a // translation switch where TanStack Router keeps ContentEditor mounted but // swaps `item` for a different id). The post-render useEffect below also // syncs item -> formData, but it runs *after* the first render with the new // item, leaving children (notably PortableTextEditor, which freezes its // initial content on mount) one render behind. This is the React-recommended // "store info from previous renders" idiom -- see // https://react.dev/reference/react/useState#storing-information-from-previous-renders // // We also reset lastSavedData here (not just in the post-render effect) so // that isDirty stays false through the switch -- otherwise SaveButton would // briefly flip from "Saved" -> "Save" -> "Saved" within a single tick. const [previousItemId, setPreviousItemId] = React.useState(item?.id ?? null); if (item && item.id !== previousItemId) { setPreviousItemId(item.id); setFormData(item.data); setSlug(item.slug || ""); setSlugTouched(!!item.slug); setStatus(item.status); const nextBylines = item.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ?? []; setInternalBylines(nextBylines); setLastSavedData( serializeEditorState({ data: item.data, slug: item.slug || "", bylines: nextBylines, }), ); pendingAutosaveStateRef.current = null; setBylinesTouched(false); } // 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, })) ?? [], }), ); pendingAutosaveStateRef.current = null; setBylinesTouched(false); } }, [item?.updatedAt, itemDataString, item?.slug, item?.status]); const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines; const handleBylinesChange = React.useCallback( (next: BylineCreditInput[]) => { setBylinesTouched(true); 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(() => { if (!lastAutosaveAt || !pendingAutosaveStateRef.current) { return; } setLastSavedData(pendingAutosaveStateRef.current); pendingAutosaveStateRef.current = null; }, [lastAutosaveAt]); const hasInvalidUrls = React.useCallback( (data: Record) => { for (const [name, field] of Object.entries(fields)) { if (field.kind === "url") { const val = typeof data[name] === "string" ? data[name].trim() : ""; if (val && !isValidUrl(val)) return true; } } return false; }, [fields], ); 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(() => { if (hasInvalidUrls(formDataRef.current)) return; const payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; } = { data: formDataRef.current, slug: slugRef.current || undefined, }; if (bylinesTouched) payload.bylines = activeBylines; pendingAutosaveStateRef.current = serializeEditorState({ data: payload.data, slug: payload.slug || "", bylines: activeBylines, }); onAutosave(payload); }, AUTOSAVE_DELAY); return () => { if (autosaveTimeoutRef.current) { clearTimeout(autosaveTimeoutRef.current); } }; }, [ currentData, isNew, onAutosave, item?.id, isDirty, isSaving, isAutosaving, activeBylines, bylinesTouched, hasInvalidUrls, ]); // Cancel pending autosave on manual save const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (hasInvalidUrls(formData)) return; // Cancel pending autosave if (autosaveTimeoutRef.current) { clearTimeout(autosaveTimeoutRef.current); autosaveTimeoutRef.current = null; } const payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; } = { data: formData, slug: slug || undefined, }; if (isNew || bylinesTouched) payload.bylines = activeBylines; onSave?.(payload); }; // Preview URL state const [isLoadingPreview, setIsLoadingPreview] = React.useState(false); const urlPattern = manifest?.collections[collection]?.urlPattern; const handlePreview = async () => { if (!item?.id) return; setIsLoadingPreview(true); try { const result = await getPreviewUrl(collection, item.id); if (result?.url) { window.open(result.url, "_blank", "noopener,noreferrer"); } else { window.open( contentUrl(collection, slug || item.id, urlPattern), "_blank", "noopener,noreferrer", ); } } catch { window.open( contentUrl(collection, slug || item?.id || "", urlPattern), "_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. In distraction-free mode this becomes a hover-revealed overlay so the chrome stays out of the way while writing. In normal mode it's a regular block; the form also renders a Save button at the bottom so save is reachable without scrolling back up. */}
{!isDistractionFree && ( } /> )} {isDistractionFree && ( )}

{isNew ? t`New ${collectionLabel}` : t`Edit ${collectionLabel}`}

{i18n && item?.locale && ( {item.locale} )}
{/* Autosave indicator */} {!isNew && onAutosave && (
{isAutosaving ? ( <> {t`Saving...`} ) : lastAutosaveAt ? ( <>
)} {!isDistractionFree && ( )} {!isNew && supportsPreview && ( )} {!isNew && ( <> {supportsDrafts && hasPendingChanges && onDiscardDraft && ( ( )} /> {t`Discard draft changes?`} {t`This will revert to the published version. Your draft changes will be lost.`}
( )} /> ( )} />
)} {isLive ? ( <> {hasPendingChanges ? ( ) : ( )} ) : ( )} {isLive && item?.slug && ( } > {t`Live View`} )} )}
{/* Main content area */}
{/* Editor fields */}
{Object.entries(fields).map(([name, field]) => { // Key by item id so all field editors remount cleanly when the // underlying content item changes (e.g. switching translations). // PortableTextEditor in particular freezes its initial content on // mount; without this key, navigating between translations leaves // the previous locale's body in the editor and silently overwrites // the new translation on the next edit. const fieldKey = `${name}:${item?.id ?? "new"}`; const fieldEl = ( ); return fieldEl; })}
{/* Save action at the bottom of the main column so users hit it naturally when they finish editing, without needing to scroll past the entire sidebar. */} {!isDistractionFree && (
)}
{/* 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 */}

{t`Publish`}

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

{t`Scheduled for: ${formatScheduledDate(item.scheduledAt)}`}

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

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

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

)} {!isNew && onDelete && (
( )} /> {t`Move to Trash?`} {t`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 && (

{t`Ownership`}

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

{t`Bylines`}

entry.byline)} bylinesLoaded={availableBylinesLoaded} onChange={handleBylinesChange} onQuickCreate={onQuickCreateByline} onQuickEdit={onQuickEditByline} // Existing entry: use its own locale. New entry: use the // URL `?locale=` (passed in via `entryLocale`). entryLocale={item?.locale ?? entryLocale} i18n={i18n} />
)} {/* Translations sidebar - shown when i18n is enabled */} {i18n && item && !isNew && (
navigate({ to: "/content/$collection/$id", params: { collection, id: tr.id }, search: { locale: tr.locale }, }) } onCreate={onTranslate} />
)} {/* Taxonomy selector */} {item && (
)} {/* SEO panel - shown for collections with hasSeo enabled */} {hasSeo && !isNew && onSeoChange && (

{t`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. * Called with the editor on mount, and with `null` on unmount. */ onEditorReady?: (editor: Editor | null) => 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 { t } = useLingui(); 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 }> | Record; 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} dir="auto" 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} dir="auto" placeholder={t`Enter markdown content...`} /> ); case "select": { const selectOptions = Array.isArray(field.options) ? field.options : []; const selectItems: Record = {}; for (const opt of selectOptions) { selectItems[opt.value] = opt.label; } return ( ); } case "multiSelect": { const multiSelectOptions = Array.isArray(field.options) ? field.options : []; const selected: string[] = Array.isArray(value) ? (value as string[]) : []; return (
{multiSelectOptions.map((opt) => { const isChecked = selected.includes(opt.value); return ( { const next = checked ? [...selected, opt.value] : selected.filter((v) => v !== opt.value); handleChange(next); }} /> ); })}
); } case "datetime": return ( handleChange(fromDatetimeLocalInputValue(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 ( ); } case "file": { // value is either a FileFieldValue object or undefined. // The file field type was unusable before this PR (rendered as a text input // that produced raw strings nobody could meaningfully save), so there is no // "legacy string" data to preserve here. const fileValue = value != null && typeof value === "object" ? (value as FileFieldValue) : undefined; return ( ); } case "repeater": { const validation = field.validation; const subFields = (validation?.subFields ?? []) as Array<{ slug: string; type: string; label: string; required?: boolean; options?: string[]; }>; return ( ); } case "json": { const jsonString = typeof value === "string" ? value : value != null ? JSON.stringify(value, null, 2) : ""; return ( ); } case "url": return ( ); default: // Default to text input return ( handleChange(e.target.value)} required={field.required} dir="auto" /> ); } } const URL_PROTOCOL_PATTERN = /^https?:\/\//; function isValidUrl(val: string): boolean { if (!URL_PROTOCOL_PATTERN.test(val)) return false; try { const url = new URL(val); if (url.protocol !== "http:" && url.protocol !== "https:") return false; if (url.hostname.includes("..")) return false; return url.hostname.includes(".") || url.hostname === "localhost"; } catch { return false; } } /** * URL field editor with validation on blur */ function UrlFieldEditor({ label, labelClass, id, value, onChange, required, placeholder, }: { label: string; labelClass?: string; id: string; value: string; onChange: (value: unknown) => void; required?: boolean; placeholder?: string; }) { const { t } = useLingui(); const [error, setError] = React.useState(null); const handleBlur = (e: React.FocusEvent) => { const val = e.target.value.trim(); if (!val) { setError(null); return; } if (!isValidUrl(val)) { setError(t`Enter a valid URL (e.g. https://example.com)`); } else { setError(null); } }; return (
{label}} id={id} type="url" value={value} onChange={(e) => { if (error) setError(null); onChange(e.target.value); }} onBlur={handleBlur} required={required} placeholder={placeholder} /> {error &&

{error}

}
); } /** * JSON field editor with syntax validation */ function JsonFieldEditor({ label, id, value, onChange, required, }: { label: string; id: string; value: string; onChange: (value: unknown) => void; required?: boolean; }) { const { t } = useLingui(); const [text, setText] = React.useState(value); const [error, setError] = React.useState(null); // Sync from parent when value changes externally React.useEffect(() => { setText(value); setError(null); }, [value]); const handleChange = (newText: string) => { setText(newText); setError(null); }; const handleBlur = () => { const trimmed = text.trim(); if (trimmed === "") { setError(null); onChange(null); return; } try { const parsed = JSON.parse(trimmed); setError(null); onChange(parsed); } catch { setError(t`Invalid JSON`); } }; return (
handleChange(e.target.value)} onBlur={handleBlur} rows={8} placeholder="{}" required={required} className="font-mono text-sm" /> {error &&

{error}

}
); } // ImageFieldRenderer (and its ImageFieldValue shape) moved to // ./ImageFieldRenderer so repeater sub-fields can reuse the picker. /** * File field value — matches the "file" shape validated by the Zod generator: * { id, provider?, src?, filename?, mimeType?, size?, meta? } */ interface FileFieldValue { id: string; /** Provider ID (e.g., "local", "s3") */ provider?: string; /** Direct URL for non-local media */ src?: string; filename?: string; mimeType?: string; size?: number; /** Provider-specific metadata */ meta?: Record; } interface FileFieldRendererProps { id?: string; label: string; value: FileFieldValue | undefined; onChange: (value: FileFieldValue | null) => void; required?: boolean; allowedMimeTypes?: string[]; fieldId?: string; } /** * File field with media picker * * Like ImageFieldRenderer but for arbitrary file types. Shows a mime-type-appropriate * icon, filename, and size instead of an image preview. */ function FileFieldRenderer({ id, label, value, onChange, required, allowedMimeTypes, fieldId, }: FileFieldRendererProps) { const { t } = useLingui(); const [pickerOpen, setPickerOpen] = React.useState(false); // Normalize value to derive display info. // For local files, prefer meta.storageKey; fall back to value.src when it's an // internal media path; finally fall back to value.id so local files remain // clickable even when metadata is sparse. For external providers, use value.src // but only when it's an http(s) URL — a hostile provider plugin could otherwise // return a data: or javascript: URL that gets rendered as a clickable link. const normalized = React.useMemo(() => { if (!value) return null; const isLocal = !value.provider || value.provider === "local"; const storageKey = typeof value.meta?.storageKey === "string" ? value.meta.storageKey : undefined; const localSrc = typeof value.src === "string" && value.src.startsWith("/_emdash/") ? value.src : undefined; // Storage keys come from server-controlled paths today, but the Zod schema // now lets clients write arbitrary `meta.storageKey` strings via the content // API. Encode before interpolating so attacker-shaped values can't escape // the path with `?` or `#`. const localUrl = isLocal ? storageKey ? `/_emdash/api/media/file/${encodeURIComponent(storageKey)}` : (localSrc ?? `/_emdash/api/media/file/${encodeURIComponent(value.id)}`) : undefined; const externalUrl = !isLocal && value.src && isSafeUrl(value.src) ? value.src : undefined; return { displayUrl: localUrl ?? externalUrl, filename: value.filename || t`Untitled file`, mimeType: value.mimeType || "", size: value.size, }; }, [value, t]); const handleSelect = (item: MediaItem) => { const isLocalProvider = !item.provider || item.provider === "local"; onChange({ id: item.id, provider: item.provider || "local", src: isLocalProvider ? undefined : item.url, filename: item.filename, mimeType: item.mimeType, size: item.size, meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta, }); }; const handleRemove = () => { onChange(null); }; const hasMime = !!normalized?.mimeType; const size = typeof normalized?.size === "number" ? normalized.size : undefined; const hasSize = size !== undefined; return (
{normalized ? (
{normalized.displayUrl ? ( {normalized.filename} ) : (

{normalized.filename}

)} {(hasMime || hasSize) && (

{hasMime ? normalized.mimeType : null} {hasMime && hasSize ? " • " : null} {hasSize ? formatFileSize(size) : null}

)}
) : ( )} {required && !normalized && (

{t`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[]; /** * Full byline details for the entry's already-selected credits. Seeded from * the saved entry so credited bylines always render their name/slug even when * they fall outside the initial (unsearched) picker list. */ selectedBylineDetails?: BylineSummary[]; onChange: (bylines: BylineCreditInput[]) => void; onQuickCreate?: (input: { slug: string; displayName: string }) => Promise; onQuickEdit?: ( bylineId: string, input: { slug: string; displayName: string }, ) => Promise; /** * Locale of the entry being edited. When the picker comes back empty and * the install is multi-locale, the empty-state copy and CTA link are * scoped to this locale (post-migration 040, the picker is strict * per-locale — see the bylines manager flow). */ entryLocale?: string | null; /** i18n config from the manifest. When set with >1 locales, the editor renders the locale-scoped empty-state. */ i18n?: { defaultLocale: string; locales: string[] } | null; /** Suppresses the empty-state until the picker query resolves. Defaults to true. */ bylinesLoaded?: boolean; } function BylineCreditsEditor({ credits, bylines, selectedBylineDetails, onChange, onQuickCreate, onQuickEdit, entryLocale, i18n, bylinesLoaded = true, }: BylineCreditsEditorProps) { const { t } = useLingui(); const [search, setSearch] = React.useState(""); const debouncedSearch = useDebouncedValue(search, 300); 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); // Server-side search so the picker isn't limited to the first page of // bylines (previously capped at 100 with no way to find the rest). When the // search box is empty we fall back to the parent-provided initial list. const trimmedSearch = debouncedSearch.trim(); const searchEnabled = trimmedSearch.length > 0; const searchResults = useQuery({ queryKey: ["bylines", "credit-picker", entryLocale ?? null, trimmedSearch], queryFn: () => fetchBylines({ search: trimmedSearch, locale: entryLocale ?? undefined, limit: 20 }), enabled: searchEnabled, placeholderData: keepPreviousData, }); const resultPool = searchEnabled ? (searchResults.data?.items ?? []) : bylines; const hasMoreResults = searchEnabled ? !!searchResults.data?.nextCursor : bylines.length >= 100; // Resolve credited bylines to their full details for display. Selected rows // come from the parent-provided details so they keep rendering even when the // current search results no longer include them. const bylineMap = React.useMemo(() => { const map = new Map(); for (const b of selectedBylineDetails ?? []) map.set(b.id, b); for (const b of bylines) map.set(b.id, b); for (const b of searchResults.data?.items ?? []) map.set(b.id, b); return map; }, [selectedBylineDetails, bylines, searchResults.data?.items]); const availableToAdd = resultPool.filter((b) => !credits.some((c) => c.bylineId === b.id)); const addByline = (bylineId: string) => { if (credits.some((c) => c.bylineId === bylineId)) return; onChange([...credits, { bylineId, roleLabel: null }]); }; 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); }; // Multi-locale install with no bylines at the entry's locale: show a // CTA to the byline manager, scoped to that locale. Quick-create // still works inline. const isMultiLocale = !!i18n && i18n.locales.length > 1; const showLocaleEmptyState = isMultiLocale && bylinesLoaded && bylines.length === 0 && !!entryLocale; return (
{showLocaleEmptyState && (

{t`No bylines available in ${entryLocale}. Create a variant from the Bylines page before crediting one on this entry.`}

{t`Manage bylines in ${entryLocale}`}
)}
setSearch(e.target.value)} placeholder={t`Search bylines to add...`} aria-label={t`Search bylines`} /> {searchEnabled && searchResults.isLoading ? (

{t`Searching...`}

) : availableToAdd.length > 0 ? (
    {availableToAdd.map((b) => (
  • ))}
) : searchEnabled && searchResults.isError ? (

{t`Couldn't search bylines. Please try again.`}

) : searchEnabled ? (

{t`No matching bylines.`}

) : null} {hasMoreResults && (

{t`Keep typing to narrow down more bylines.`}

)}
{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); }} />
); })}
) : (

{t`No bylines selected.`}

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

{quickError}

}
( )} />
)} {onQuickEdit && editBylineId && ( (!open ? resetQuickEdit() : undefined)}> {t`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 { t } = useLingui(); const currentAuthor = users.find((u) => u.id === authorId); const authorItems: Record = { unassigned: t`Unassigned` }; for (const user of users) { authorItems[user.id] = user.name || user.email; } return (
{currentAuthor &&

{currentAuthor.email}

}
); }