/** * Widgets page component * * Manage widget areas and widgets with drag-and-drop support. * Available widgets can be dragged from the palette into widget areas. * Widgets within an area can be reordered via drag-and-drop. */ import { Button, Dialog, Input, Label, Select, Switch, Toast } from "@cloudflare/kumo"; import { DndContext, DragOverlay, type CollisionDetection, type DragEndEvent, type DragStartEvent, KeyboardSensor, closestCenter, rectIntersection, useSensor, useSensors, useDraggable, useDroppable, PointerSensor, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Plus, DotsSixVertical, Trash, CaretDown } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { fetchManifest, fetchWidgetAreas, fetchWidgetComponents, fetchMenus, createWidgetArea, createWidget, updateWidget, deleteWidget, deleteWidgetArea, reorderWidgets, type WidgetArea, type Widget, type WidgetComponent, type CreateWidgetInput, type UpdateWidgetInput, } from "../lib/api"; import { getPluginBlocks } from "../lib/pluginBlocks"; import { CaretNext } from "./ArrowIcons.js"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { DialogError, getMutationError } from "./DialogError.js"; import { ImageDetailPanel, type ImageAttributes } from "./editor/ImageDetailPanel"; import { PortableTextEditor, type BlockSidebarPanel, type PluginBlockDef, } from "./PortableTextEditor"; /** Palette item types that can be dragged into areas */ interface PaletteItemData { source: "palette"; widgetInput: CreateWidgetInput; label: string; } /** Identifies an existing widget being reordered */ interface ExistingWidgetData { source: "area"; areaName: string; } type DragItemData = PaletteItemData | ExistingWidgetData; function isPaletteItem(data: DragItemData): data is PaletteItemData { return data.source === "palette"; } /** Built-in widget types available in the palette */ const BUILTIN_WIDGETS: Array<{ id: string; label: MessageDescriptor; description: MessageDescriptor; input: CreateWidgetInput; }> = [ { id: "palette-content", label: msg`Content Block`, description: msg`Rich text content`, input: { type: "content" }, }, { id: "palette-menu", label: msg`Menu`, description: msg`Display a navigation menu`, input: { type: "menu" }, }, ]; export function Widgets() { const { t } = useLingui(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const [isCreateAreaOpen, setIsCreateAreaOpen] = React.useState(false); const [createAreaError, setCreateAreaError] = React.useState(null); const [activeId, setActiveId] = React.useState(null); const [activeDragData, setActiveDragData] = React.useState(null); const [expandedWidgets, setExpandedWidgets] = React.useState>(new Set()); const [blockSidebarPanel, setBlockSidebarPanel] = React.useState(null); // Track palette drag source across the full drag lifecycle (including drop animation) const draggingFromPaletteRef = React.useRef(false); const handleBlockSidebarOpen = React.useCallback((panel: BlockSidebarPanel) => { setBlockSidebarPanel((prev) => { // Close any existing panel before opening a new one so only one is ever active prev?.onClose(); return panel; }); }, []); const handleBlockSidebarClose = React.useCallback(() => { setBlockSidebarPanel((prev) => { prev?.onClose(); return null; }); }, []); const { data: areas = [], isLoading } = useQuery({ queryKey: ["widget-areas"], queryFn: fetchWidgetAreas, }); const { data: components = [] } = useQuery({ queryKey: ["widget-components"], queryFn: fetchWidgetComponents, }); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const pluginBlocks = React.useMemo(() => (manifest ? getPluginBlocks(manifest) : []), [manifest]); const createAreaMutation = useMutation({ mutationFn: createWidgetArea, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["widget-areas"] }); setIsCreateAreaOpen(false); toastManager.add({ title: t`Widget area created` }); }, onError: (error: Error) => { setCreateAreaError(error.message); }, }); const createWidgetMutation = useMutation({ mutationFn: ({ areaName, input }: { areaName: string; input: CreateWidgetInput }) => createWidget(areaName, input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["widget-areas"] }); toastManager.add({ title: t`Widget added` }); }, onError: (error: Error) => { toastManager.add({ title: t`Error adding widget`, description: error.message, type: "error", }); }, }); const handleCreateArea = (e: React.FormEvent) => { e.preventDefault(); setCreateAreaError(null); const formData = new FormData(e.currentTarget); const nameVal = formData.get("name"); const labelVal = formData.get("label"); const descVal = formData.get("description"); createAreaMutation.mutate({ name: typeof nameVal === "string" ? nameVal : "", label: typeof labelVal === "string" ? labelVal : "", description: typeof descVal === "string" ? descVal : "", }); }; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); // Custom collision detection: palette items use rectIntersection (anywhere // over the area counts) and only match area:* droppables. Existing widgets // use closestCenter for precise reorder positioning. const collisionDetection: CollisionDetection = React.useCallback((args) => { const dragData = args.active.data.current as DragItemData | undefined; if (dragData && isPaletteItem(dragData)) { // Only consider area droppables, use generous rect intersection const areaContainers = args.droppableContainers.filter((c) => String(c.id).startsWith("area:"), ); return rectIntersection({ ...args, droppableContainers: areaContainers }); } return closestCenter(args); }, []); const handleDragStart = (event: DragStartEvent) => { const id = String(event.active.id); const data = (event.active.data.current as DragItemData) ?? null; setActiveId(id); setActiveDragData(data); draggingFromPaletteRef.current = data !== null && isPaletteItem(data); }; const reorderMutation = useMutation({ mutationFn: ({ areaName, widgetIds }: { areaName: string; widgetIds: string[] }) => reorderWidgets(areaName, widgetIds), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["widget-areas"] }); }, onError: (error: Error) => { toastManager.add({ title: t`Error reordering widgets`, description: error.message, type: "error", }); }, }); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; const dragData = active.data.current as DragItemData | undefined; setActiveId(null); setActiveDragData(null); if (!over || !dragData) return; // Case 1: Dragging from palette into an area if (isPaletteItem(dragData)) { const overId = String(over.id); // The drop target is a widget area (droppable id = "area:{name}") if (overId.startsWith("area:")) { const areaName = overId.slice(5); createWidgetMutation.mutate({ areaName, input: dragData.widgetInput, }); } return; } // Case 2: Reordering within an area if (active.id === over.id) return; const sourceArea = areas.find((area) => area.widgets?.some((w) => w.id === active.id)); if (!sourceArea?.widgets) return; const oldIndex = sourceArea.widgets.findIndex((w) => w.id === active.id); const newIndex = sourceArea.widgets.findIndex((w) => w.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const newWidgets = [...sourceArea.widgets]; const [movedWidget] = newWidgets.splice(oldIndex, 1); if (!movedWidget) return; newWidgets.splice(newIndex, 0, movedWidget); reorderMutation.mutate({ areaName: sourceArea.name, widgetIds: newWidgets.map((w) => w.id), }); }; const toggleWidget = (widgetId: string) => { setExpandedWidgets((prev) => { const next = new Set(prev); if (next.has(widgetId)) { next.delete(widgetId); } else { next.add(widgetId); } return next; }); }; // Build the palette label for the drag overlay const activePaletteLabel = activeDragData && isPaletteItem(activeDragData) ? activeDragData.label : null; // Find the existing widget being dragged for overlay const activeWidget = activeId && activeDragData && !isPaletteItem(activeDragData) ? areas.flatMap((a) => a.widgets ?? []).find((w) => w.id === activeId) : null; if (isLoading) { return (
{t`Loading widgets...`}
); } return (

{t`Widgets`}

{t`Manage content widgets in your widget areas`}

{ setIsCreateAreaOpen(open); if (!open) setCreateAreaError(null); }} > ( )} />
{t`Create Widget Area`} ( )} />
{/* Available Widgets (draggable palette) */}

{t`Available Widgets`}

{t`Drag widgets into an area to add them`}

{BUILTIN_WIDGETS.map((item) => ( ))} {components.map((comp) => ( ))}
{/* Widget Areas (droppable + sortable) */}
{areas.length === 0 ? (

{t`No widget areas yet. Create one to get started.`}

) : ( areas.map((area) => ( )) )}
{/* Drag overlay — no drop animation for palette items (source stays in place). Use ref because state is cleared in handleDragEnd before animation runs. */} {activePaletteLabel ? (
{activePaletteLabel}
) : activeWidget ? (
{activeWidget.title || t`Untitled Widget`} ({activeWidget.type})
) : null}
{/* A single block-sidebar panel for the whole page — ensures only one is ever open at a time, preventing stacked fixed overlays and duplicated window listeners. */} {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} /> )}
); } /** A draggable item in the available widgets palette */ function DraggablePaletteItem({ id, label, description, widgetInput, }: { id: string; label: string; description?: string; widgetInput: CreateWidgetInput; }) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id, data: { source: "palette", widgetInput, label, } satisfies PaletteItemData, }); return (
{label}
{description &&
{description}
}
); } function WidgetAreaPanel({ area, expandedWidgets, onToggleWidget, isDraggingPalette, components, pluginBlocks, onBlockSidebarOpen, onBlockSidebarClose, }: { area: WidgetArea; expandedWidgets: Set; onToggleWidget: (id: string) => void; isDraggingPalette: boolean; components: WidgetComponent[]; pluginBlocks: PluginBlockDef[]; onBlockSidebarOpen: (panel: BlockSidebarPanel) => void; onBlockSidebarClose: () => void; }) { const { t } = useLingui(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const [deleteAreaName, setDeleteAreaName] = React.useState(null); // Make the area a droppable target for palette items const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `area:${area.name}`, }); const deleteAreaMutation = useMutation({ mutationFn: deleteWidgetArea, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["widget-areas"] }); setDeleteAreaName(null); toastManager.add({ title: t`Widget area deleted` }); }, }); const hasWidgets = area.widgets && area.widgets.length > 0; return (

{area.label}

{area.description &&

{area.description}

}
{hasWidgets ? ( w.id)} strategy={verticalListSortingStrategy} > {area.widgets!.map((widget) => ( onToggleWidget(widget.id)} components={components} pluginBlocks={pluginBlocks} onBlockSidebarOpen={onBlockSidebarOpen} onBlockSidebarClose={onBlockSidebarClose} /> ))} ) : null} {/* Drop zone hint — shown when dragging a palette item */} {isDraggingPalette && (
{isOver ? t`Drop to add widget` : t`Drag here to add`}
)} {!hasWidgets && !isDraggingPalette && (
{t`Drag widgets here to add them`}
)}
{ setDeleteAreaName(null); deleteAreaMutation.reset(); }} title={t`Delete Widget Area?`} description={t`This will delete the widget area and all its widgets. This action cannot be undone.`} confirmLabel={t`Delete`} pendingLabel={t`Deleting...`} isPending={deleteAreaMutation.isPending} error={deleteAreaMutation.error} onConfirm={() => deleteAreaMutation.mutate(area.name)} />
); } function WidgetItem({ widget, areaName, isExpanded, onToggle, components, pluginBlocks, onBlockSidebarOpen, onBlockSidebarClose, }: { widget: Widget; areaName: string; isExpanded: boolean; onToggle: () => void; components: WidgetComponent[]; pluginBlocks: PluginBlockDef[]; onBlockSidebarOpen: (panel: BlockSidebarPanel) => void; onBlockSidebarClose: () => void; }) { const { t } = useLingui(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: widget.id, data: { source: "area", areaName, } satisfies ExistingWidgetData, }); const style = { transform: CSS.Transform.toString(transform), transition, }; const deleteMutation = useMutation({ mutationFn: () => deleteWidget(areaName, widget.id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["widget-areas"] }); toastManager.add({ title: t`Widget deleted` }); }, onError: (error: Error) => { toastManager.add({ title: t`Error`, description: error.message, type: "error", }); }, }); const updateMutation = useMutation({ mutationFn: (input: UpdateWidgetInput) => updateWidget(areaName, widget.id, input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["widget-areas"] }); toastManager.add({ title: t`Widget updated` }); }, onError: (error: Error) => { toastManager.add({ title: t`Error updating widget`, description: error.message, type: "error", }); }, }); return (
{isExpanded && ( updateMutation.mutate(input)} isSaving={updateMutation.isPending} onBlockSidebarOpen={onBlockSidebarOpen} onBlockSidebarClose={onBlockSidebarClose} /> )}
); } /** Inline editor form for a widget, rendered when the widget is expanded */ function WidgetEditor({ widget, components, pluginBlocks, onSave, isSaving, onBlockSidebarOpen, onBlockSidebarClose, }: { widget: Widget; components: WidgetComponent[]; pluginBlocks: PluginBlockDef[]; onSave: (input: UpdateWidgetInput) => void; isSaving: boolean; onBlockSidebarOpen: (panel: BlockSidebarPanel) => void; onBlockSidebarClose: () => void; }) { const { t } = useLingui(); const [title, setTitle] = React.useState(widget.title ?? ""); const [content, setContent] = React.useState( Array.isArray(widget.content) ? widget.content : [], ); const [menuName, setMenuName] = React.useState(widget.menuName ?? ""); const [componentId, setComponentId] = React.useState(widget.componentId ?? ""); const [componentProps, setComponentProps] = React.useState>( widget.componentProps ?? {}, ); const { data: menus = [] } = useQuery({ queryKey: ["menus"], queryFn: () => fetchMenus(), enabled: widget.type === "menu", }); const selectedComponent = components.find((c) => c.id === componentId); const handleSave = () => { const input: UpdateWidgetInput = { title }; if (widget.type === "content") { input.content = content; } else if (widget.type === "menu") { input.menuName = menuName; } else if (widget.type === "component") { input.componentId = componentId; input.componentProps = componentProps; } onSave(input); }; return (
setTitle(e.target.value)} placeholder={t`Widget title`} /> {widget.type === "content" && (
[0]["value"]} onChange={(value) => setContent(value as unknown[])} minimal placeholder={t`Write widget content...`} pluginBlocks={pluginBlocks} onBlockSidebarOpen={onBlockSidebarOpen} onBlockSidebarClose={onBlockSidebarClose} />
)} {widget.type === "menu" && ( )} {widget.type === "component" && ( <> {selectedComponent && Object.entries(selectedComponent.props).map(([key, def]) => ( setComponentProps((prev) => ({ ...prev, [key]: v }))} /> ))} )}
); } /** Renders a single prop field for a component widget based on PropDef type */ function ComponentPropField({ def, value, onChange, }: { propKey: string; def: WidgetComponent["props"][string]; value: unknown; onChange: (value: unknown) => void; }) { switch (def.type) { case "string": return ( onChange(e.target.value)} /> ); case "number": return ( onChange(Number(e.target.value))} /> ); case "boolean": return ; case "select": { const items: Record = {}; for (const opt of def.options ?? []) { items[opt.value] = opt.label; } return ( ); } default: return ( onChange(e.target.value)} /> ); } }