/** * Section editor page component * * Edit a section's content and metadata. */ import { Input, InputArea, Label, Loader, Toast } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useParams, useNavigate } from "@tanstack/react-router"; import * as React from "react"; import { fetchSection, updateSection, type Section, type UpdateSectionInput } from "../lib/api"; import { slugify } from "../lib/utils"; import { ArrowPrev } from "./ArrowIcons.js"; import { ImageDetailPanel, type ImageAttributes } from "./editor/ImageDetailPanel"; import { EditorHeader } from "./EditorHeader"; import { PortableTextEditor, type BlockSidebarPanel } from "./PortableTextEditor"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; export function SectionEditor() { const { t } = useLingui(); const { slug } = useParams({ from: "/_admin/sections/$slug" }); const navigate = useNavigate(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const { data: section, isLoading, error, } = useQuery({ queryKey: ["sections", slug], queryFn: () => fetchSection(slug), staleTime: Infinity, }); const updateMutation = useMutation({ mutationFn: (input: UpdateSectionInput) => updateSection(slug, input), onSuccess: (updated) => { void queryClient.invalidateQueries({ queryKey: ["sections"] }); void queryClient.invalidateQueries({ queryKey: ["sections", slug] }); toastManager.add({ title: t`Section saved` }); // If slug changed, navigate to new URL if (updated.slug !== slug) { void navigate({ to: "/sections/$slug", params: { slug: updated.slug } }); } }, onError: (mutationError: Error) => { toastManager.add({ title: t`Error saving section`, description: mutationError.message, type: "error", }); }, }); if (isLoading) { return (
); } if (error || !section) { return (
} />

{t`Section Not Found`}

{error ? error.message : t`Section "${slug}" could not be found.`}

); } return ( updateMutation.mutate(input)} /> ); } interface SectionEditorFormProps { section: Section; isSaving: boolean; onSave: (input: UpdateSectionInput) => void; } function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps) { const { t } = useLingui(); const [title, setTitle] = React.useState(section.title); const [sectionSlug, setSectionSlug] = React.useState(section.slug); const [slugTouched, setSlugTouched] = React.useState(true); // Existing sections have touched slugs const [description, setDescription] = React.useState(section.description || ""); const [keywords, setKeywords] = React.useState(section.keywords.join(", ")); const [content, setContent] = React.useState(section.content); // Track initial state for dirty checking const [lastSavedData] = React.useState(() => JSON.stringify({ title: section.title, slug: section.slug, description: section.description || "", keywords: section.keywords.join(", "), content: section.content, }), ); // Auto-generate slug from title if editing title and slug hasn't been manually changed React.useEffect(() => { if (!slugTouched && title && title !== section.title) { setSectionSlug(slugify(title)); } }, [title, slugTouched, section.title]); const currentData = React.useMemo( () => JSON.stringify({ title, slug: sectionSlug, description, keywords, content }), [title, sectionSlug, description, keywords, content], ); const isDirty = currentData !== lastSavedData; // Block sidebar state populated when a node view (e.g. ImageNode) requests // sidebar space. 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 handleSave = () => { const keywordsArray = keywords .split(",") .map((k) => k.trim()) .filter(Boolean); onSave({ title, slug: sectionSlug, description: description || undefined, keywords: keywordsArray, content, }); }; return (
} /> } actions={} >

{section.title}

{section.source === "theme" ? t`Theme Section` : t`Custom Section`} ·{" "} {section.slug}

{/* Main content */}
{/* Content editor */}
[0]["value"]} onChange={(value) => setContent(value as unknown[])} onBlockSidebarOpen={handleBlockSidebarOpen} onBlockSidebarClose={handleBlockSidebarClose} />
{/* 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. */}
{/* Sidebar */}
{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 /> ) : ( <> {/* Metadata */}

{t`Section Details`}

setTitle(e.target.value)} placeholder={t`Section title`} />
{ setSectionSlug(e.target.value); setSlugTouched(true); }} placeholder="section-slug" pattern="[a-z0-9\-]+" />

{t`Used to identify this section. Lowercase letters, numbers, and hyphens only.`}

setDescription(e.target.value)} placeholder={t`Describe what this section is for...`} rows={3} />
setKeywords(e.target.value)} placeholder={t`hero, banner, cta`} />

{t`Comma-separated keywords for search.`}

{/* Source info */}

{t`Source`}

{section.source === "theme" && ( <> {t`This section is provided by the theme. Editing will create a custom copy that overrides the theme version.`} )} {section.source === "user" && <>{t`This is a custom section.`}} {section.source === "import" && ( <>{t`This section was imported from another system.`} )}

{section.themeId && (

{t`Theme ID: ${section.themeId}`}

)}
)}
); }