diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 2227f1f..fa4a39f 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -1,12 +1,12 @@ import { Navigate } from 'react-router'; import { href, useNav } from './routes'; +import { pageParam, usePages } from './usePages'; import { DEFAULT_SECTION } from './sections'; import { sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; import { LayoutPanel } from './sections/LayoutPanel'; -import { PagesPanel } from './sections/PagesPanel'; import { PageBuilderPanel } from './sections/PageBuilderPanel'; import { ViewModeProvider } from './ViewMode'; import { MessagesPanel } from './MessagesPanel'; @@ -46,7 +46,6 @@ import { useReady } from './useReady'; */ const PANELS: Partial React.JSX.Element>> = { layout: LayoutPanel, - pages: PagesPanel, galleries: GalleriesPanel, messages: MessagesPanel, administration: AdministrationPanel, @@ -63,6 +62,7 @@ export const DetailPanel = () => { addressable: `/fofos-admin/pages` is the list, `/fofos-admin/pages/home` is that page. */ const { section: selected, item: selectedPage, atIndex } = useNav(); + const { pages } = usePages(); const { ready, rootSet, loading } = useReady(); @@ -92,6 +92,26 @@ export const DetailPanel = () => { const wanted = sectionFor(selected); const current = sectionOpen(wanted, { ready, rootSet }) ? wanted : sectionFor('administration'); + /* + Pages has no panel of its own any more. + + It was a list of cards — a title, a slug, a menu label, per page — beside a nav that already lists + the pages. Two lists of the same thing, and the one you were looking at was never the one you had + clicked. The nav IS the list now, ordered by dragging it, so the section itself is only a way in. + + `/fofos-admin/pages` therefore means "the first one", which is what somebody pressing Pages wants + and what the nav's own row already implied. + */ + if (current.key === 'pages' && !selectedPage) { + const first = pages?.[0]; + if (first) return ; + return ( +
+ {pages ? 'This site has no pages yet.' : 'Loading…'} +
+ ); + } + const Panel = current.key === 'pages' && selectedPage ? PageBuilderPanel : current.built ? PANELS[current.key] : undefined; diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 4841208..2babaa2 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router'; import { useClient } from 'hooks/useClient'; -import { Lock } from 'lucide-react'; +import { GripVertical, Lock } from 'lucide-react'; import { SECTIONS, sectionOpen } from './sections'; import { href, useNav } from './routes'; import { useReady } from './useReady'; @@ -80,6 +80,50 @@ export const NavPanel = () => { .catch(() => {}); }, [pages, ready, announce]); + /* + Reordering, live. + + The list is rearranged as the pointer crosses a row rather than on drop, so what you see while + dragging is what you will get — and the row under the cursor is the one that moves out of the way, + which is the only feedback a list this dense has room for. + + It is announced on the channel, so the page builder's own list agrees with the nav immediately. + The write happens once, at the end: `PUT /pages/order` takes the whole set, because an order is a + property of the SET and sending one row's new index leaves the server guessing about everyone else. + */ + const [dragged, setDragged] = useState(null); + const orderedRef = useRef(null); + + const moveOver = (event: React.DragEvent, overId: number) => { + if (dragged === null || !pages) return; + // Without this the drop is refused and the browser animates the row back to where it started. + event.preventDefault(); + if (dragged === overId) return; + + const next = [...pages]; + const from = next.findIndex((page) => page.id === dragged); + const to = next.findIndex((page) => page.id === overId); + if (from < 0 || to < 0) return; + + next.splice(to, 0, next.splice(from, 1)[0]!); + orderedRef.current = next; + announce(next); + }; + + const commitOrder = () => { + const ordered = orderedRef.current; + setDragged(null); + orderedRef.current = null; + // Dropped where it started, or dropped outside the list. Nothing moved, so nothing is written. + if (!ordered) return; + + void clientRef.current + .put('/fofos-admin/pages/order', { ids: ordered.map((page) => page.id) }) + // The list is already showing the new order; a failure has to put the old one back, and the + // server is the only thing that knows what that was. + .catch(() => void clientRef.current.get<{ data: Page[] }>('/fofos-admin/pages').then((body) => announce(body.data))); + }; + const renderRow = (section: (typeof SECTIONS)[number]) => { const Icon = section.icon; // Each section asks its own question now — Media wants a root location, the site's pages want a @@ -151,19 +195,47 @@ export const NavPanel = () => { const current = active === 'pages' && item === pageParam(page); return ( - ` is draggable by default and drags its URL, so making the row itself + draggable fights the browser for what the gesture means. The grip owns the gesture; + the row owns where it lands. + */ + onDragOver={(event) => moveOver(event, page.id)} + onDrop={commitOrder} + className={`flex items-center border-b border-border transition-colors ${ current ? 'bg-muted' : 'hover:bg-muted/50' - }`} + } ${dragged === page.id ? 'opacity-40' : ''}`} > - {page.title} - {/* The address, because two pages can be called nearly the same thing and this is what - tells them apart. Home's is `/`, which is short enough not to need explaining. */} - {page.path} - + { + setDragged(page.id); + // Firefox starts no drag at all without data on the transfer. The value is unused. + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(page.id)); + }} + onDragEnd={commitOrder} + className="flex shrink-0 cursor-grab items-center py-1.5 pr-1 pl-3 text-muted-foreground/60 active:cursor-grabbing" + aria-label={`Reorder ${page.title}`} + > + + + + + {page.title} + {/* The address, because two pages can be called nearly the same thing and this is what + tells them apart. Home's is `/`, which is short enough not to need explaining. */} + {page.path} + + ); })}