Home stays first, and a reorder shows in the preview

Home has no grip, and dropping onto it is refused — hiding the handle alone
would still let something be dragged above the site's front door.

Every preview now reloads on the change channel, not just the page builder's own
writes. The Layout preview draws the top menu and had no reload token at all, so
the one screen where a reorder is visible was the one that never showed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:05:32 +01:00
co-authored by Claude Opus 5
parent ca023eb651
commit d2ac0aca1b
2 changed files with 33 additions and 2 deletions
+18
View File
@@ -6,6 +6,7 @@ import { SECTIONS, sectionOpen } from './sections';
import { href, useNav } from './routes'; import { href, useNav } from './routes';
import { useReady } from './useReady'; import { useReady } from './useReady';
import { pageParam, usePages, type Page } from './usePages'; import { pageParam, usePages, type Page } from './usePages';
import { useFofosChanged } from './channel';
/** /**
* The left column: which part of the fofos site the detail panel is showing. * The left column: which part of the fofos site the detail panel is showing.
@@ -91,11 +92,15 @@ export const NavPanel = () => {
The write happens once, at the end: `PUT /pages/order` takes the whole set, because an order is a 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. property of the SET and sending one row's new index leaves the server guessing about everyone else.
*/ */
const [, bump] = useFofosChanged();
const [dragged, setDragged] = useState<number | null>(null); const [dragged, setDragged] = useState<number | null>(null);
const orderedRef = useRef<Page[] | null>(null); const orderedRef = useRef<Page[] | null>(null);
const moveOver = (event: React.DragEvent, overId: number) => { const moveOver = (event: React.DragEvent, overId: number) => {
if (dragged === null || !pages) return; if (dragged === null || !pages) return;
// Home is first and stays first — see the note on its missing grip. Refusing the drop here is what
// stops anything being dragged ABOVE it, which no amount of hiding the handle would prevent.
if (pages.find((page) => page.id === overId)?.is_home) return;
// Without this the drop is refused and the browser animates the row back to where it started. // Without this the drop is refused and the browser animates the row back to where it started.
event.preventDefault(); event.preventDefault();
if (dragged === overId) return; if (dragged === overId) return;
@@ -119,6 +124,8 @@ export const NavPanel = () => {
void clientRef.current void clientRef.current
.put('/fofos-admin/pages/order', { ids: ordered.map((page) => page.id) }) .put('/fofos-admin/pages/order', { ids: ordered.map((page) => page.id) })
// The menu is the site's, so every preview on the screen is now showing the old one.
.then(() => bump())
// The list is already showing the new order; a failure has to put the old one back, and the // 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. // 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))); .catch(() => void clientRef.current.get<{ data: Page[] }>('/fofos-admin/pages').then((body) => announce(body.data)));
@@ -210,6 +217,16 @@ export const NavPanel = () => {
current ? 'bg-muted' : 'hover:bg-muted/50' current ? 'bg-muted' : 'hover:bg-muted/50'
} ${dragged === page.id ? 'opacity-40' : ''}`} } ${dragged === page.id ? 'opacity-40' : ''}`}
> >
{/*
Home has no grip.
It is the site's front door and its address is `/`; a menu that opens somewhere else
is not a menu with a different order, it is a different site. The space is kept so the
titles still line up — an unindented Home would read as a heading rather than a row.
*/}
{page.is_home ? (
<span className="w-6 shrink-0" aria-hidden />
) : (
<span <span
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
@@ -224,6 +241,7 @@ export const NavPanel = () => {
> >
<GripVertical className="size-3.5" /> <GripVertical className="size-3.5" />
</span> </span>
)}
<Link <Link
to={href('pages', pageParam(page))} to={href('pages', pageParam(page))}
+15 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { readableError } from './errors'; import { readableError } from './errors';
import { DEVICES, usePreviewable, useViewMode } from './ViewMode'; import { DEVICES, usePreviewable, useViewMode } from './ViewMode';
import { useFofosChanged } from './channel';
/** /**
* A page of the real site, embedded. * A page of the real site, embedded.
@@ -209,9 +210,21 @@ export const SitePreview = ({
The lesson is the same one as `useClient` two files along: a value rebuilt every render must not be The lesson is the same one as `useClient` two files along: a value rebuilt every render must not be
the thing something else keys on. the thing something else keys on.
*/ */
/*
A change anywhere on the site reloads every preview of it.
`reloadToken` is the panel's own — the page builder bumps it after its own writes. This is the
other half: something ELSE changed the site, and a frame showing the old one is a frame lying. The
nav reordering the menu is the case that made it necessary; the Layout preview draws that menu and
had no token at all.
Its purpose is exactly this — "anyone reading it should read it again" — and a preview is a reader.
*/
const [changed] = useFofosChanged();
useEffect(() => { useEffect(() => {
setLoaded(0); setLoaded(0);
}, [reloadToken]); }, [reloadToken, changed]);
useEffect(() => { useEffect(() => {
if (!loaded) return; if (!loaded) return;
@@ -334,7 +347,7 @@ export const SitePreview = ({
<iframe <iframe
// The key is what makes `reloadToken` reload: a new key is a new element, so the browser // The key is what makes `reloadToken` reload: a new key is a new element, so the browser
// fetches the document again and `onLoad` runs, which re-injects the builder's chrome. // fetches the document again and `onLoad` runs, which re-injects the builder's chrome.
key={reloadToken ?? 0} key={`${reloadToken ?? 0}:${changed}`}
ref={frame} ref={frame}
// Applied on load as well as on change: the document is replaced on a reload, and the // Applied on load as well as on change: the document is replaced on a reload, and the
// properties go with it. // properties go with it.