From ca023eb6516599c4f1c0a508b65cd6efcea55cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 09:02:02 +0100 Subject: [PATCH] pages reorder by dragging; the list panel is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grip drags and the row is the drop target — a Link is draggable by default and drags its URL, so making the row itself draggable fights the browser for what the gesture means. The list rearranges as the pointer crosses a row rather than on drop, and the write happens once at the end: PUT /pages/order takes the whole set, because an order is a property of the set. /fofos-admin/pages was a list of cards 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. It redirects to the first page now. Co-Authored-By: Claude Opus 5 (1M context) --- web/DetailPanel.tsx | 24 +++++++++++- web/NavPanel.tsx | 96 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 106 insertions(+), 14 deletions(-) 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} + + ); })}