A third level, under the selected page only — every page has bands, and listing all of them would make the nav longer than the site. Announced by the builder rather than fetched again: it already reads them to know what it is looking at in the document, and it mounts exactly when a page is open. Cleared on unmount, or the nav keeps listing the last page's bands. Clicking one writes a third URL segment and the preview scrolls to it, keyed on the render count too so the position survives a save replacing the frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { GripVertical, Lock } from 'lucide-react';
|
|
import { SECTIONS, sectionOpen } from './sections';
|
|
import { href, useNav } from './routes';
|
|
import { useReady } from './useReady';
|
|
import { pageParam, usePages, useSections, type Page } from './usePages';
|
|
import { useFofosChanged } from './channel';
|
|
|
|
/**
|
|
* The left column: which part of the fofos site the detail panel is showing.
|
|
*
|
|
* ── Selection is in the URL ──
|
|
*
|
|
* `?section=…`, per `docs/navigation-audit.md`: what is on screen is addressable, so it survives a
|
|
* reload, can be linked, and the detail panel reads it directly. The two panels are siblings in a
|
|
* workspace and never speak to each other.
|
|
*
|
|
* ── Real links, not buttons ──
|
|
*
|
|
* `AGENTS.md`: "Rows and nav items are real links." `plugins/proxy-manager/web/NavPanel.tsx` uses
|
|
* buttons calling `setParams`, which keeps the URL correct but gives up anchor semantics — no
|
|
* cmd-click into a new tab, no middle-click, not link-focusable. That is the older pattern and the
|
|
* file that documents it says not to copy it.
|
|
*
|
|
* `<Link>` rather than `<NavLink>` because active state here is a QUERY parameter and NavLink's
|
|
* `isActive` compares paths — every section would light up at once. So the active class is computed
|
|
* from the same value the detail panel reads, which is the one place it can come from.
|
|
*
|
|
* ── Locked until `useReady` ──
|
|
*
|
|
* Until the plugin is ready, the site's sections are not links at all — a `<div>`, dimmed, with no
|
|
* href to cmd-click and nothing to focus. Rendering a `<Link>` and swallowing the click would leave a
|
|
* row that looks navigable, offers a URL on hover and opens in a new tab perfectly happily.
|
|
*
|
|
* The nav only declines to OFFER the door. `DetailPanel` holds the lock, because a typed URL never
|
|
* comes through here.
|
|
*/
|
|
|
|
/**
|
|
* The nav has a FOOT.
|
|
*
|
|
* Everything above it is a part of the client's site, in sitemap order. Below the divider is this
|
|
* plugin's own machinery, which is not a page and does not belong in a list of them — the same split
|
|
* the plugins nav makes with Update-all, and the reason it sits at the bottom of the panel rather than
|
|
* at the bottom of the list. `mt-auto` pins it there whether there are ten sections or two.
|
|
*/
|
|
|
|
const rowClass = (active: boolean) =>
|
|
`flex w-full items-center gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
|
|
active ? 'bg-muted' : 'hover:bg-muted/50'
|
|
}`;
|
|
|
|
export const NavPanel = () => {
|
|
const { pages, announce } = usePages();
|
|
const { section: active, item, part } = useNav();
|
|
const { sections } = useSections();
|
|
const { ready, rootSet } = useReady();
|
|
|
|
/*
|
|
The pages, fetched HERE when nobody has said yet.
|
|
|
|
They were announced by PagesPanel and only by PagesPanel — which mounts when Pages is the open
|
|
section, so the site's pages were invisible in the nav until you had already been to the page that
|
|
lists them. The one place they most need to be is the one place they were not.
|
|
|
|
The channel stays and is still the reason the nav renames a page the moment the editor does: two
|
|
fetchers would agree until somebody typed. This only fills a silence.
|
|
*/
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
useEffect(() => {
|
|
if (pages !== null || !ready) return;
|
|
void clientRef.current
|
|
.get<{ data: Page[] }>('/fofos-admin/pages')
|
|
.then((body) => announce(body.data))
|
|
// Left null: the nav then draws no children, which is what it did before and is not a failure
|
|
// worth a message in a list of links.
|
|
.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 [, bump] = useFofosChanged();
|
|
const [dragged, setDragged] = useState<number | null>(null);
|
|
const orderedRef = useRef<Page[] | null>(null);
|
|
|
|
const moveOver = (event: React.DragEvent, overId: number) => {
|
|
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.
|
|
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 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
|
|
// 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
|
|
// connection, Administration wants nothing because it is the way out of both.
|
|
const locked = !sectionOpen(section, { ready, rootSet });
|
|
|
|
const body = (
|
|
<>
|
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-baseline gap-2">
|
|
<span className={`truncate text-sm ${section.built ? '' : 'text-muted-foreground'}`}>{section.label}</span>
|
|
{/* Dimmed and labelled rather than hidden. A nav that shows only what is finished gives
|
|
no sense of what the thing is for — see sections.ts. */}
|
|
{!section.built && (
|
|
<span className="text-[10px] tracking-wide text-muted-foreground/70 uppercase">soon</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
{locked && <Lock className="size-3.5 shrink-0 text-muted-foreground/60" />}
|
|
</>
|
|
);
|
|
|
|
if (locked) {
|
|
return (
|
|
<div key={section.key} aria-disabled className={`${rowClass(false)} cursor-not-allowed opacity-45`}>
|
|
{body}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const row = (
|
|
<Link
|
|
key={section.key}
|
|
/*
|
|
A PATH, and no query string at all.
|
|
|
|
It used to build a fresh `URLSearchParams` per link, because everything about where you were
|
|
lived in the query and a copied one leaked — pressing Layout while a band was open gave
|
|
`?section=layout&band=footer`, so the row for a whole section led to one part of it.
|
|
|
|
A path cannot leak: the URL says where you are, and `href` drops the query because what is
|
|
left in it — how you are looking, not at what — belongs to the screen you are leaving.
|
|
*/
|
|
to={href(section.key)}
|
|
replace
|
|
className={rowClass(active === section.key)}
|
|
>
|
|
{body}
|
|
</Link>
|
|
);
|
|
|
|
/*
|
|
The site's pages, as rows under Pages.
|
|
|
|
Indented and quieter than a section, because they are not sections — they are the contents of
|
|
one. The indent is a left border rather than padding alone, so the relationship survives a row
|
|
wrapping onto two lines.
|
|
|
|
Drawn whether or not Pages is the open section. The list of pages IS the site, and hiding it
|
|
until you click makes the nav a menu of menus.
|
|
*/
|
|
if (section.key !== 'pages' || !pages?.length) return row;
|
|
|
|
return (
|
|
<div key={section.key} className="flex flex-col">
|
|
{row}
|
|
{pages.map((page) => {
|
|
const current = active === 'pages' && item === pageParam(page);
|
|
|
|
return (
|
|
<div key={page.id} className="flex flex-col">
|
|
<div
|
|
/*
|
|
The ROW is the drop target and the HANDLE is what drags.
|
|
|
|
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 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' : ''}`}
|
|
>
|
|
{/*
|
|
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
|
|
draggable
|
|
onDragStart={(event) => {
|
|
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}`}
|
|
>
|
|
<GripVertical className="size-3.5" />
|
|
</span>
|
|
)}
|
|
|
|
<Link
|
|
to={href('pages', pageParam(page))}
|
|
replace
|
|
className="flex min-w-0 flex-1 items-baseline gap-2 py-1.5 pr-3 pl-1 text-left"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs">{page.title}</span>
|
|
{/* 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. */}
|
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
|
</Link>
|
|
</div>
|
|
|
|
{/*
|
|
The open page's bands, one level further in.
|
|
|
|
Only under the page that is SELECTED — every page has bands, and listing all of them
|
|
would make the nav longer than the site. Announced by the builder, which is mounted
|
|
exactly when a page is open; see useSections.
|
|
*/}
|
|
{current &&
|
|
sections?.map((band) => (
|
|
<Link
|
|
key={band.id}
|
|
to={href('pages', pageParam(page), String(band.id))}
|
|
replace
|
|
className={`flex items-baseline border-b border-border py-1.5 pr-3 pl-14 text-left transition-colors ${
|
|
part === String(band.id) ? 'bg-muted' : 'hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{band.name}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<nav className="flex size-full flex-col overflow-auto">
|
|
<div className="flex flex-col">{SECTIONS.filter((section) => !section.pinned).map(renderRow)}</div>
|
|
|
|
{/* Separated by the same border the rows use, so it reads as the end of the panel rather than as
|
|
one more page of the site. `border-t` and not `border-t-2`: it is a divider, not a heading. */}
|
|
<div className="mt-auto flex flex-col border-t border-border">
|
|
{/* Said once, here, rather than on nine dimmed rows. Without it a locked nav reads as broken
|
|
rather than as one step not done — the rows go grey and nothing says why or what to do. */}
|
|
{!ready && (
|
|
<p className="px-3 pt-2.5 text-xs text-muted-foreground">
|
|
{rootSet ? 'Connect fofos below to open the rest.' : 'Set a root location below to begin.'}
|
|
</p>
|
|
)}
|
|
{SECTIONS.filter((section) => section.pinned).map(renderRow)}
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|