A fourth level under the open band only — a page of six bands with four blocks each is thirty rows, and the nav stops being somewhere you can find anything. Blocks are named by the same list the picker offers, so a thing is called the same word wherever it appears. Clicking a block in the PAGE now writes that address too, rather than calling the handler directly. Both ways of picking a block are the same act, so neither can disagree with the other about what is selected — and closing it is the band row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
333 lines
15 KiB
TypeScript
333 lines
15 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 { CHOICES } from './sections/AddComponentDialog';
|
|
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, piece } = 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) => (
|
|
<div key={band.id} className="flex flex-col">
|
|
<Link
|
|
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) && !piece ? 'bg-muted' : 'hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{band.name}</span>
|
|
</Link>
|
|
|
|
{/*
|
|
The band's blocks, one level further in again.
|
|
|
|
Under the OPEN band only, for the same reason the bands are under the open page:
|
|
a page of six bands with four blocks each is thirty rows, and the nav stops being
|
|
a place you can find anything.
|
|
|
|
Named by kind, using the same list the block picker offers — so a thing is called
|
|
the same word wherever it appears.
|
|
*/}
|
|
{part === String(band.id) &&
|
|
band.components.map((block) => (
|
|
<Link
|
|
key={block.id}
|
|
to={href('pages', pageParam(page), String(band.id), String(block.id))}
|
|
replace
|
|
className={`flex items-baseline border-b border-border py-1 pr-3 pl-20 text-left transition-colors ${
|
|
piece === String(block.id) ? 'bg-muted' : 'hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/80">
|
|
{CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
))}
|
|
</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>
|
|
);
|
|
};
|