/** * Menu Editor component * * Edit menu items with basic reordering (simplified version without drag-and-drop) */ import { Button, Dialog, Input, Select, Toast } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Plus, Trash, CaretUp, CaretDown, Link as LinkIcon, X, File as FileIcon, } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import * as React from "react"; import { fetchMenu, createMenuItem, deleteMenuItem, updateMenuItem, reorderMenuItems, fetchMenuTranslations, createMenuTranslation, type MenuItem, } from "../lib/api"; import { fetchManifest } from "../lib/api/client.js"; import { ArrowPrev } from "./ArrowIcons.js"; import { ContentPickerModal } from "./ContentPickerModal"; import { DialogError, getMutationError } from "./DialogError.js"; import { useI18nConfig } from "./LocaleSwitcher.js"; import { TranslationsPanel } from "./TranslationsPanel.js"; export function MenuEditor() { const { t } = useLingui(); const { name } = useParams({ from: "/_admin/menus/$name" }); const search = useSearch({ from: "/_admin/menus/$name" }); const routeLocale = search.locale; const navigate = useNavigate(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const [isAddOpen, setIsAddOpen] = React.useState(false); const [isContentPickerOpen, setIsContentPickerOpen] = React.useState(false); const [editingItem, setEditingItem] = React.useState(null); const [localItems, setLocalItems] = React.useState([]); const [addError, setAddError] = React.useState(null); const [editError, setEditError] = React.useState(null); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const i18n = useI18nConfig(manifest); const { data: menu, isLoading } = useQuery({ queryKey: ["menu", name, routeLocale ?? null], queryFn: () => fetchMenu(name, { locale: routeLocale }), staleTime: Infinity, }); // The locale we lock mutations to: explicit URL param wins; else fall back // to whatever the loaded menu row says (handles entry from the old /menus/$name // URL without a locale query). const menuLocale = routeLocale ?? menu?.locale; const { data: translationsData } = useQuery({ queryKey: ["menu-translations", name, menuLocale ?? null], queryFn: () => fetchMenuTranslations(name, { locale: menuLocale }), enabled: !!menu && !!i18n && i18n.locales.length > 1, }); const translateMutation = useMutation({ mutationFn: (targetLocale: string) => createMenuTranslation( name, { locale: targetLocale, label: menu?.label }, { locale: menuLocale }, ), onSuccess: (translated) => { void queryClient.invalidateQueries({ queryKey: ["menus"] }); void queryClient.invalidateQueries({ queryKey: ["menu", name] }); void queryClient.invalidateQueries({ queryKey: ["menu-translations", name] }); toastManager.add({ title: t`Translation created`, description: t`Menu "${translated.label}" (${translated.locale.toUpperCase()}) created.`, }); // Switch the editor to the new locale so the user keeps editing. void navigate({ to: "/menus/$name", params: { name }, search: { locale: translated.locale }, }); }, onError: (error: Error) => { toastManager.add({ title: t`Error`, description: error.message, type: "error", }); }, }); // Sync local items with fetched data React.useEffect(() => { if (menu?.items) { setLocalItems(menu.items); } }, [menu]); const createMutation = useMutation({ mutationFn: (input: Parameters[1]) => createMenuItem(name, input, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); setIsAddOpen(false); toastManager.add({ title: t`Item added`, description: t`Menu item has been added.` }); }, onError: (error: Error) => { setAddError(error.message); }, }); const deleteMutation = useMutation({ mutationFn: (itemId: string) => deleteMenuItem(name, itemId, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); toastManager.add({ title: t`Item deleted`, description: t`Menu item has been deleted.`, }); }, onError: (error: Error) => { toastManager.add({ title: t`Error`, description: error.message, type: "error", }); }, }); const updateMutation = useMutation({ mutationFn: ({ itemId, input, }: { itemId: string; input: Parameters[2]; }) => updateMenuItem(name, itemId, input, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); setEditingItem(null); toastManager.add({ title: t`Item updated`, description: t`Menu item has been updated.`, }); }, onError: (error: Error) => { setEditError(error.message); }, }); const reorderMutation = useMutation({ mutationFn: (input: Parameters[1]) => reorderMenuItems(name, input, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); toastManager.add({ title: t`Order saved`, description: t`Menu order has been updated.`, }); }, onError: (error: Error) => { toastManager.add({ title: t`Error`, description: error.message, type: "error", }); }, }); const handleAddCustomLink = (e: React.FormEvent) => { e.preventDefault(); setAddError(null); const formData = new FormData(e.currentTarget); const labelVal = formData.get("label"); const urlVal = formData.get("url"); const targetVal = formData.get("target"); createMutation.mutate({ type: "custom", label: typeof labelVal === "string" ? labelVal : "", customUrl: typeof urlVal === "string" ? urlVal : "", target: (typeof targetVal === "string" ? targetVal : "") || undefined, }); }; const handleAddContent = (item: { collection: string; id: string; title: string }) => { // The API's menuItemTypeEnum accepts singular values // ("custom" | "page" | "post" | "taxonomy" | "collection"), but the // ContentPickerModal hands us the collection slug (e.g. "pages", // "posts", or any custom collection slug). Map the slug to the // matching enum value and let the API resolve the real URL from // referenceCollection + referenceId. const type = item.collection === "pages" ? "page" : item.collection === "posts" ? "post" : "collection"; createMutation.mutate({ type, label: item.title, referenceCollection: item.collection, referenceId: item.id, }); }; const handleUpdateItem = (e: React.FormEvent) => { e.preventDefault(); setEditError(null); if (!editingItem) return; const formData = new FormData(e.currentTarget); const uLabelVal = formData.get("label"); const uUrlVal = formData.get("url"); const uTargetVal = formData.get("target"); updateMutation.mutate({ itemId: editingItem.id, input: { label: typeof uLabelVal === "string" ? uLabelVal : "", customUrl: editingItem.type === "custom" ? (typeof uUrlVal === "string" ? uUrlVal : "") : undefined, target: (typeof uTargetVal === "string" ? uTargetVal : "") || undefined, }, }); }; const moveItem = (index: number, direction: "up" | "down") => { const newItems = [...localItems]; const targetIndex = direction === "up" ? index - 1 : index + 1; if (targetIndex < 0 || targetIndex >= newItems.length) return; const currentItem = newItems[index]; const targetItem = newItems[targetIndex]; if (!currentItem || !targetItem) return; newItems[index] = targetItem; newItems[targetIndex] = currentItem; // Update sort orders const reorderedItems = newItems.map((item, i) => ({ id: item.id, parentId: item.parentId, sortOrder: i, })); setLocalItems(newItems); reorderMutation.mutate({ items: reorderedItems }); }; if (isLoading) { return (
{t`Loading menu...`}
); } if (!menu) { return (

{t`Menu not found`}

); } return (

{menu.label}

{t`Edit menu items`}

{ setIsAddOpen(open); if (!open) setAddError(null); }} > ( )} />
{t`Add Custom Link`} ( )} />
{i18n && i18n.locales.length > 1 && menu ? (
({ id: tr.id, locale: tr.locale })) ?? [ { id: menu.id, locale: menu.locale }, ] } onOpen={(tr) => navigate({ to: "/menus/$name", params: { name }, search: { locale: tr.locale }, }) } onCreate={(target) => translateMutation.mutate(target)} pendingLocale={ translateMutation.isPending ? (translateMutation.variables ?? null) : null } />
) : null} {localItems.length === 0 ? (

{t`No menu items yet`}

{t`Add links to build your navigation menu`}

) : (
{localItems.map((item, index) => (
{item.label}
{item.type === "custom" ? ( item.customUrl ) : ( {item.referenceCollection ?? item.type} )} {item.target === "_blank" && t` (opens in new window)`}
))}
)} { if (!open) { setEditingItem(null); setEditError(null); } }} >
{t`Edit Menu Item`} ( )} />
{editingItem && (
{editingItem.type === "custom" && ( )}
)}
); }