/**
* 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 */}
{t`Content`}
[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 */}
{/* 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}`}
)}
>
)}
);
}