import { closestCenter, DndContext, type DragEndEvent, type DragStartEvent, KeyboardSensor, MeasuringStrategy, type Modifier, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { sortableKeyboardCoordinates, SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useLingui } from "@lingui/react/macro"; import { DotsSixVertical } from "@phosphor-icons/react"; import * as React from "react"; import { parseContentSettingsLayout, reorderContentSettingsLayout, resolveContentSettingsLayout, type ContentSettingsLayout, type ContentSettingsSectionId, } from "../lib/content-settings-layout.js"; import { cn } from "../lib/utils.js"; const STORAGE_PREFIX = "emdash:content-settings-layout:v1"; const restrictToVerticalAxis: Modifier = ({ transform }) => ({ ...transform, x: 0, }); export interface SortableContentSettingsSectionProps { id: ContentSettingsSectionId; label: string; /** Leaves room for an existing disclosure chevron at the inline end. */ disclosure?: boolean; children: React.ReactNode; /** Internal state supplied by the sortable group while any section is moving. */ isSorting?: boolean; } interface SortableContentSettingsSectionsProps { collection: string; userId?: string; onSortingChange?: (isSorting: boolean) => void; children: React.ReactNode; } function readStoredLayout(storageKey: string | null): ContentSettingsLayout | null { if (!storageKey || typeof window === "undefined") return null; try { return parseContentSettingsLayout(window.localStorage.getItem(storageKey)); } catch { return null; } } function writeStoredLayout(storageKey: string | null, layout: ContentSettingsLayout): void { if (!storageKey || typeof window === "undefined") return; try { window.localStorage.setItem(storageKey, JSON.stringify(layout)); } catch { // Browser storage is optional; the reordered in-memory layout still works. } } export function SortableContentSettingsSections({ collection, userId, onSortingChange, children, }: SortableContentSettingsSectionsProps) { const storageKey = userId ? `${STORAGE_PREFIX}:${encodeURIComponent(userId)}:${encodeURIComponent(collection)}` : null; // Keep the server and first client render identical. Browser preferences // are restored after hydration so a saved order cannot cause a mismatch. const [storedLayout, setStoredLayout] = React.useState(null); const [activeId, setActiveId] = React.useState(null); React.useEffect(() => { setStoredLayout(readStoredLayout(storageKey)); }, [storageKey]); const layout = React.useMemo(() => resolveContentSettingsLayout(storedLayout), [storedLayout]); const sectionsById = React.useMemo(() => { const sections = React.Children.toArray(children).filter( (child): child is React.ReactElement => React.isValidElement(child), ); return new Map(sections.map((section) => [section.props.id, section])); }, [children]); const visibleIds = React.useMemo( () => layout.order.filter((id) => sectionsById.has(id)), [layout.order, sectionsById], ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleDragStart = React.useCallback( (event: DragStartEvent) => { setActiveId(String(event.active.id) as ContentSettingsSectionId); onSortingChange?.(true); }, [onSortingChange], ); const handleDragCancel = React.useCallback(() => { setActiveId(null); onSortingChange?.(false); }, [onSortingChange]); const handleDragEnd = React.useCallback( (event: DragEndEvent) => { setActiveId(null); onSortingChange?.(false); if (event.over && event.active.id !== event.over.id) { const movedId = String(event.active.id) as ContentSettingsSectionId; const overId = String(event.over.id) as ContentSettingsSectionId; setStoredLayout((current) => { const next = reorderContentSettingsLayout( resolveContentSettingsLayout(current), movedId, overId, ); writeStoredLayout(storageKey, next); return next; }); } }, [onSortingChange, storageKey], ); return ( {visibleIds.map((id) => { const section = sectionsById.get(id); return section ? React.cloneElement(section, { key: id, isSorting: activeId !== null }) : null; })} ); } export function SortableContentSettingsSection({ id, label, disclosure = false, children, isSorting = false, }: SortableContentSettingsSectionProps) { const { t } = useLingui(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, }); const style: React.CSSProperties = { transform: transform ? CSS.Transform.toString({ ...transform, x: 0 }) : undefined, transition, zIndex: isDragging ? 10 : undefined, inlineSize: "100%", }; return (
*:not([data-sortable-heading]):not([data-sortable-handle])]:hidden", isDragging && "bg-kumo-tint opacity-60", )} > {isSorting && (
{label}
)} {children}
); }