import { useState } from 'react' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import Modal from '../../../components/Modal.jsx' import { Row } from './NavEditor.jsx' // The Public tab of Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10). // // The public header is the one nav an admin can restructure rather than only // reorder, so it needs its own editor: a **section is itself an entry in the // top-level order**, which the fixed coded sections of the admin sidebar never // are. That is the whole reason this is not the grouped editor with a different // label — there, groups are a fixed frame and only membership moves. // // The tree is `[{kind: 'item' | 'link' | 'section', ...}]`, one level deep, and // comes from the same `buildPublicNav` the header renders, so what an admin // drags is what visitors get. const uid = (prefix) => `${prefix}_${Math.random().toString(36).slice(2, 10)}` // A path on this site, matching what the server will accept. Checked here so the // admin gets the message while the field is in front of them; the server's 400 // stays the backstop, not the first feedback. export function badLinkPath(value) { const v = (value || '').trim() if (!v) return 'Enter a path.' if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('//')) { return 'Links must point somewhere on this site — start with “/”.' } if (!v.startsWith('/')) return 'Start the path with “/”, for example /wiki/new-player-guide.' if (/[\s<>"'\\]/.test(v)) return 'A path cannot contain spaces or quotes.' if (v.length > 128) return 'That path is too long.' return null } function SectionCard({ section, index, children, onChange, onDelete }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id }) return (
  • onChange({ ...section, label: e.target.value })} aria-label={`Name for section ${index + 1}`} style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem', fontWeight: 600 }} /> dropdown
    {children}
  • ) } export default function PublicNavTree({ tree, onChange }) { const [adding, setAdding] = useState(null) // {label, to, error} while the modal is open const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) const sections = tree.filter((n) => n.kind === 'section') const destinations = [{ value: '', label: 'Top level' }, ...sections.map((s) => ({ value: s.id, label: s.label || 'Section' }))] const keyOf = (node) => (node.kind === 'item' ? node.to : node.id) // Every mutation rebuilds the tree; there is no partial in-place editing, which // keeps "what will be saved" exactly "what is on screen". const replace = (nextTree) => onChange(nextTree) const updateNode = (key, next) => replace( tree.map((node) => { if (keyOf(node) === key) return next if (node.kind !== 'section') return node return { ...node, items: node.items.map((child) => (keyOf(child) === key ? next : child)) } }), ) // Moving between containers is the dropdown, not a drag. The entry lands at the // end of its destination, where it is visible and can then be dragged home. const moveTo = (key, sectionId) => { let moving = null const stripped = tree .map((node) => { if (node.kind === 'section') { const items = node.items.filter((child) => { if (keyOf(child) !== key) return true moving = child return false }) return { ...node, items } } if (keyOf(node) === key) { moving = node return null } return node }) .filter(Boolean) if (!moving) return if (!sectionId) return replace([...stripped, moving]) return replace( stripped.map((node) => (node.kind === 'section' && node.id === sectionId ? { ...node, items: [...node.items, moving] } : node)), ) } const addSection = () => replace([...tree, { kind: 'section', id: uid('sec'), label: 'New section', items: [] }]) // Deleting a section must NOT delete what is inside it: those are coded pages // and the admin's own links, and losing them to a mis-click would be the one // destructive act this screen could commit. They move back to the top level. const deleteSection = (id) => { const section = tree.find((n) => n.kind === 'section' && n.id === id) if (!section) return replace([...tree.filter((n) => keyOf(n) !== id), ...(section.items || [])]) } const deleteLink = (id) => replace( tree .filter((n) => keyOf(n) !== id) .map((n) => (n.kind === 'section' ? { ...n, items: n.items.filter((c) => keyOf(c) !== id) } : n)), ) const submitLink = () => { const error = badLinkPath(adding.to) if (error) return setAdding({ ...adding, error }) const label = adding.label.trim() if (!label) return setAdding({ ...adding, error: 'Give the link a name.' }) replace([...tree, { kind: 'link', id: uid('lnk'), label, to: adding.to.trim() }]) return setAdding(null) } const onDragEnd = (containerId) => (event) => { const { active, over } = event if (!over || active.id === over.id) return if (containerId === null) { const from = tree.findIndex((n) => keyOf(n) === active.id) const to = tree.findIndex((n) => keyOf(n) === over.id) if (from < 0 || to < 0) return return replace(arrayMove(tree, from, to)) } return replace( tree.map((node) => { if (node.kind !== 'section' || node.id !== containerId) return node const from = node.items.findIndex((c) => keyOf(c) === active.id) const to = node.items.findIndex((c) => keyOf(c) === over.id) if (from < 0 || to < 0) return node return { ...node, items: arrayMove(node.items, from, to) } }), ) } const renderRow = (node, sectionId) => ( moveTo(keyOf(node), value)} onChange={(next) => updateNode(keyOf(node), next)} onDelete={node.kind === 'link' ? () => deleteLink(node.id) : undefined} /> ) return (
      {tree.map((node, index) => node.kind === 'section' ? ( updateNode(node.id, next)} onDelete={() => deleteSection(node.id)} > {/* A nested context, so a drag inside a dropdown reorders that dropdown rather than escaping into the header. */}
        {(node.items || []).map((child) => renderRow(child, node.id))} {(node.items || []).length === 0 && (
      • Empty — an empty dropdown is not shown on the site.
      • )}
      ) : ( renderRow(node, null) ), )}
    {adding && ( setAdding(null)} width={480} footer={ <> } >

    Links point somewhere on this site — a wiki page, a custom page, any section of the site. They are not gated: the page itself still decides who may open it, so a link to something restricted behaves exactly as typing its address would.

    {adding.error && {adding.error}}
    )}
    ) }