import { Badge, Button, Dialog, Input, Label, LinkButton, Loader, Select, Text, } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { ArrowSquareOut, Eye, EyeSlash, Trash, Upload, X } 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, ContentSeoInput, TranslationSummary, UserListItem, } from "../lib/api"; import { fetchBylines } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; import { cn, slugify } from "../lib/utils"; import type { CurrentUserInfo } from "./ContentEditor.js"; import { DocumentOutline } from "./editor/DocumentOutline"; import { GalleryDetailPanel } from "./editor/GalleryDetailPanel"; import type { GalleryAttributes } from "./editor/GalleryNode"; import { ImageDetailPanel } from "./editor/ImageDetailPanel"; import type { ImageAttributes } from "./editor/ImageDetailPanel"; import type { BlockSidebarPanel } from "./PortableTextEditor"; import { RevisionHistory } from "./RevisionHistory"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; import { SeoPanel } from "./SeoPanel"; import { SortableContentSettingsSection, SortableContentSettingsSections, } from "./SortableContentSettingsSections.js"; import { TaxonomySidebar, useHasApplicableTaxonomies } from "./TaxonomySidebar"; import { TranslationsPanel } from "./TranslationsPanel.js"; // Editor role level (40) from @emdash-cms/auth const ROLE_EDITOR = 40; /** Format scheduled date for display */ function formatScheduledDate(dateStr: string | null) { if (!dateStr) return null; const date = new Date(dateStr); return date.toLocaleString(); } /** * Discard-draft confirmation shared by the settings action bar and the * distraction-free overlay, so the copy and behavior can't drift. */ export function DiscardDraftDialog({ onDiscard, triggerVariant = "ghost", triggerSize, }: { onDiscard?: () => void; triggerVariant?: "ghost" | "outline"; triggerSize?: "sm"; }) { const { t } = useLingui(); return ( ( )} /> {t`Discard draft changes?`} {t`This will revert to the published version. Your draft changes will be lost.`}
( )} /> ( )} />
); } export interface SettingsActionBarProps { collectionLabel?: string; isNew?: boolean; isDirty: boolean; isSaving: boolean; /** Autosave in flight — reported by the save button's busy state. */ isAutosaving?: boolean; /** Preserve operation blocking independently of the visual feedback state. */ saveDisabled?: boolean; isLive: boolean; hasPendingChanges: boolean; liveViewUrl?: string | null; supportsPreview?: boolean; isLoadingPreview?: boolean; onPreview?: () => void; onPublish?: () => void; onUnpublish?: () => void; announceSaveStatus?: boolean; } function SettingsActionSlot({ children }: React.PropsWithChildren) { return (
{children}
); } export interface PreviewButtonProps { hasPendingChanges: boolean; isLoadingPreview?: boolean; onPreview?: () => void; size?: "sm"; } export function PreviewButton({ hasPendingChanges, isLoadingPreview, onPreview, size, }: PreviewButtonProps) { const { t } = useLingui(); return ( ); } export interface PublishActionsProps { collectionLabel?: string; isNew?: boolean; isLive: boolean; hasPendingChanges: boolean; onPublish?: () => void; onUnpublish?: () => void; size?: "sm"; } export function PublishActions({ collectionLabel, isNew, isLive, hasPendingChanges, onPublish, onUnpublish, size, }: PublishActionsProps) { const { t } = useLingui(); const itemLabel = collectionLabel ?? t`content`; if (isNew) return null; if (!isLive) { return ( ); } if (hasPendingChanges) { return ( ); } return ( ); } /** * Single action row pinned above the settings panel body. Publish-state * context lives in the Publish section below so the sidebar has one action * surface and one status surface. * * Deliberately NOT memoized — it exists so high-frequency props * (isDirty, isSaving, isAutosaving) stop here instead of busting the * memoized panel body below it. */ export function SettingsActionBar({ collectionLabel, isNew, isDirty, isSaving, isAutosaving, saveDisabled, isLive, hasPendingChanges, liveViewUrl, supportsPreview, isLoadingPreview, onPreview, onPublish, onUnpublish, announceSaveStatus, }: SettingsActionBarProps) { const { t } = useLingui(); return (
{liveViewUrl && ( } > {t`Live View`} )} {!isNew && supportsPreview && ( )} {!isNew && ( )}
); } export interface ContentSettingsPanelProps { collection: string; item?: ContentItem | null; isNew?: boolean; /** Locale this entry is bound to (URL `?locale=` for new entries). */ entryLocale?: string | null; slug: string; onSlugChange: (value: string) => void; status: string; supportsDrafts: boolean; isLive: boolean; hasPendingChanges: boolean; hasSchedule: boolean; supportsRevisions: boolean; canSchedule: boolean; onSchedule?: (scheduledAt: string) => void; onUnschedule?: () => void; isScheduling?: boolean; onDiscardDraft?: () => void; onDelete?: () => void; isDeleting?: boolean; currentUser?: CurrentUserInfo; users?: UserListItem[]; onAuthorChange?: (authorId: string | null) => void; activeBylines: BylineCreditInput[]; availableBylines?: BylineSummary[]; availableBylinesLoaded?: boolean; onBylinesChange: (next: BylineCreditInput[]) => void; onQuickCreateByline?: (input: { slug: string; displayName: string }) => Promise; onQuickEditByline?: ( bylineId: string, input: { slug: string; displayName: string }, ) => Promise; i18n?: { defaultLocale: string; locales: string[] }; translations?: TranslationSummary[]; onTranslate?: (locale: string) => void; hasSeo: boolean; onSeoChange?: (seo: ContentSeoInput) => void; /** portableText editor for the document outline (null when none mounted) */ portableTextEditor: Editor | null; /** When set, the panel shows the block's detail panel instead of settings */ blockSidebarPanel: BlockSidebarPanel | null; onBlockSidebarClose: () => void; onBlockSidebarDelete: () => void; } /** * Content settings sidebar: publish controls, ownership, bylines, * translations, taxonomies, SEO, document outline, and revision history. * * Memoized — ContentEditor re-renders on every keystroke (formData state), * and this subtree is expensive (queries + lists). All handler props must be * referentially stable or the memo is defeated. */ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ collection, item, isNew, entryLocale, slug, onSlugChange, status, supportsDrafts, isLive, hasPendingChanges, hasSchedule, supportsRevisions, canSchedule, onSchedule, onUnschedule, isScheduling, onDiscardDraft, onDelete, isDeleting, currentUser, users, onAuthorChange, activeBylines, availableBylines, availableBylinesLoaded, onBylinesChange, onQuickCreateByline, onQuickEditByline, i18n, translations, onTranslate, hasSeo, onSeoChange, portableTextEditor, blockSidebarPanel, onBlockSidebarClose, onBlockSidebarDelete, }: ContentSettingsPanelProps) { const { t } = useLingui(); const navigate = useNavigate(); const [scheduleDate, setScheduleDate] = React.useState(""); const [showScheduler, setShowScheduler] = React.useState(false); const [isReorderingSections, setIsReorderingSections] = React.useState(false); const showDiscard = !isNew && supportsDrafts && hasPendingChanges && !!onDiscardDraft; const hasApplicableTaxonomies = useHasApplicableTaxonomies(collection); const handleScheduleSubmit = () => { if (scheduleDate && onSchedule) { const date = new Date(scheduleDate); onSchedule(date.toISOString()); setShowScheduler(false); setScheduleDate(""); } }; if (blockSidebarPanel) { // A block requesting the sidebar replaces the default sections. return blockSidebarPanel.type === "image" ? (
blockSidebarPanel.onUpdate(attrs)} onReplace={(attrs) => blockSidebarPanel.onReplace(attrs as unknown as Record) } onDelete={onBlockSidebarDelete} onClose={onBlockSidebarClose} inline />
) : blockSidebarPanel.type === "gallery" ? (
blockSidebarPanel.onUpdate(attrs)} onDelete={onBlockSidebarDelete} onClose={onBlockSidebarClose} inline />
) : null; } return ( // The Kumo Sidebar wrapper sets `whitespace-nowrap` for its collapse // animation, which would stop long field descriptions from wrapping.
{t`Publish`}
onSlugChange(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)} )}
{showDiscard && (
)}
{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()}
)}
{currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && (
{t`Ownership`}
)} {currentUser && currentUser.role >= ROLE_EDITOR && (
{t`Bylines`} entry.byline)} bylinesLoaded={availableBylinesLoaded} onChange={onBylinesChange} 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} />
)} {i18n && item && !isNew && (
navigate({ to: "/content/$collection/$id", params: { collection, id: tr.id }, search: { locale: tr.locale }, }) } onCreate={onTranslate} />
)} {/* Do not register an empty sortable row when this collection has no taxonomies. */} {item && hasApplicableTaxonomies && ( )} {hasSeo && !isNew && onSeoChange && (
{t`SEO`}
)} {portableTextEditor && (
)} {!isNew && item && supportsRevisions && (
)}
{!isNew && onDelete && (
( )} /> {t`Move to Trash?`} {t`This will move the item to trash. You can restore it later from the trash.`}
( )} /> ( )} />
)}
); }); 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`} className="w-full" /> {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}

}
); }