the open page lists its bands in the nav
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>
This commit is contained in:
+26
-3
@@ -5,7 +5,7 @@ import { GripVertical, Lock } from 'lucide-react';
|
|||||||
import { SECTIONS, sectionOpen } from './sections';
|
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, useSections, type Page } from './usePages';
|
||||||
import { useFofosChanged } from './channel';
|
import { useFofosChanged } from './channel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +54,8 @@ const rowClass = (active: boolean) =>
|
|||||||
|
|
||||||
export const NavPanel = () => {
|
export const NavPanel = () => {
|
||||||
const { pages, announce } = usePages();
|
const { pages, announce } = usePages();
|
||||||
const { section: active, item } = useNav();
|
const { section: active, item, part } = useNav();
|
||||||
|
const { sections } = useSections();
|
||||||
const { ready, rootSet } = useReady();
|
const { ready, rootSet } = useReady();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -202,8 +203,8 @@ export const NavPanel = () => {
|
|||||||
const current = active === 'pages' && item === pageParam(page);
|
const current = active === 'pages' && item === pageParam(page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={page.id} className="flex flex-col">
|
||||||
<div
|
<div
|
||||||
key={page.id}
|
|
||||||
/*
|
/*
|
||||||
The ROW is the drop target and the HANDLE is what drags.
|
The ROW is the drop target and the HANDLE is what drags.
|
||||||
|
|
||||||
@@ -254,6 +255,28 @@ export const NavPanel = () => {
|
|||||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
+9
-4
@@ -32,14 +32,16 @@ export const MOUNT = '/fofos-admin';
|
|||||||
* redirects, so the address bar ends up saying where you are. `section` is still defaulted, so
|
* redirects, so the address bar ends up saying where you are. `section` is still defaulted, so
|
||||||
* everything that only wants to draw a highlight has one answer rather than a branch.
|
* everything that only wants to draw a highlight has one answer rather than a branch.
|
||||||
*/
|
*/
|
||||||
export const useNav = (): { section: SectionKey; item: string | null; atIndex: boolean } => {
|
export const useNav = (): { section: SectionKey; item: string | null; part: string | null; atIndex: boolean } => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const rest = pathname.startsWith(MOUNT) ? pathname.slice(MOUNT.length) : '';
|
const rest = pathname.startsWith(MOUNT) ? pathname.slice(MOUNT.length) : '';
|
||||||
const [first = '', second = ''] = rest.replace(/^\//, '').split('/');
|
const [first = '', second = '', third = ''] = rest.replace(/^\//, '').split('/');
|
||||||
return {
|
return {
|
||||||
// An unknown segment falls back rather than 404s: the URL is a thing people type and edit.
|
// An unknown segment falls back rather than 404s: the URL is a thing people type and edit.
|
||||||
section: isSectionKey(first) ? first : DEFAULT_SECTION,
|
section: isSectionKey(first) ? first : DEFAULT_SECTION,
|
||||||
item: second ? decodeURIComponent(second) : null,
|
item: second ? decodeURIComponent(second) : null,
|
||||||
|
/** Something inside the item — a band of the open page. Nothing else uses a third segment yet. */
|
||||||
|
part: third ? decodeURIComponent(third) : null,
|
||||||
atIndex: first === '',
|
atIndex: first === '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -51,5 +53,8 @@ export const useNav = (): { section: SectionKey; item: string | null; atIndex: b
|
|||||||
* you are looking at the Layout preview; carrying it into Galleries would be state from one screen
|
* you are looking at the Layout preview; carrying it into Galleries would be state from one screen
|
||||||
* deciding something on another. A panel that wants a parameter to survive says so at its own link.
|
* deciding something on another. A panel that wants a parameter to survive says so at its own link.
|
||||||
*/
|
*/
|
||||||
export const href = (section: SectionKey, item?: string | null): string =>
|
export const href = (section: SectionKey, item?: string | null, part?: string | null): string => {
|
||||||
item ? `${MOUNT}/${section}/${encodeURIComponent(item)}` : `${MOUNT}/${section}`;
|
if (!item) return `${MOUNT}/${section}`;
|
||||||
|
const base = `${MOUNT}/${section}/${encodeURIComponent(item)}`;
|
||||||
|
return part ? `${base}/${encodeURIComponent(part)}` : base;
|
||||||
|
};
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { useEditorSlot } from '../useEditorSlot';
|
|||||||
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
||||||
import { MediaPicker } from '../MediaPicker';
|
import { MediaPicker } from '../MediaPicker';
|
||||||
import { GalleryPicker } from '../GalleryPicker';
|
import { GalleryPicker } from '../GalleryPicker';
|
||||||
import { pageFromParam, usePages, type Page } from '../usePages';
|
import { pageFromParam, usePages, useSections, type Page } from '../usePages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One page, being built.
|
* One page, being built.
|
||||||
@@ -57,10 +57,14 @@ export const PageBuilderPanel = () => {
|
|||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
const { item: slug } = useNav();
|
const { item: slug, part } = useNav();
|
||||||
|
|
||||||
const devPort = useDevPort();
|
const devPort = useDevPort();
|
||||||
const { pages, announce } = usePages();
|
const { pages, announce } = usePages();
|
||||||
|
const { announceSections } = useSections();
|
||||||
|
// Cleared when this unmounts, or the nav goes on listing the last page's bands under whatever is
|
||||||
|
// selected next.
|
||||||
|
useEffect(() => () => announceSections(null), [announceSections]);
|
||||||
|
|
||||||
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
|
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
|
||||||
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
|
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
|
||||||
@@ -160,6 +164,21 @@ export const PageBuilderPanel = () => {
|
|||||||
*/
|
*/
|
||||||
const frameDoc = useRef<Document | null>(null);
|
const frameDoc = useRef<Document | null>(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The band named by the third URL segment, brought into view.
|
||||||
|
|
||||||
|
That segment is what the nav's rows under the open page write, and this is what makes them do
|
||||||
|
something. `rendered` is a dependency as well as `part`: the frame is replaced on every write, and
|
||||||
|
a scroll issued against the old document would be lost — this way the position survives a save.
|
||||||
|
|
||||||
|
The band is found by `data-section-id`, the marker the site already carries for the admin.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!part) return;
|
||||||
|
const band = frameDoc.current?.querySelector(`[data-section-id="${CSS.escape(part)}"]`);
|
||||||
|
band?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, [part, rendered]);
|
||||||
|
|
||||||
const onDocument = useCallback(
|
const onDocument = useCallback(
|
||||||
(frameDocument: Document) => {
|
(frameDocument: Document) => {
|
||||||
frameDoc.current = frameDocument;
|
frameDoc.current = frameDocument;
|
||||||
@@ -246,6 +265,9 @@ export const PageBuilderPanel = () => {
|
|||||||
.get<{ data: CanvasSection[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
|
.get<{ data: CanvasSection[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setSections(response.data);
|
setSections(response.data);
|
||||||
|
// The nav draws these as rows under the open page — see useSections for why it is announced
|
||||||
|
// rather than fetched a second time.
|
||||||
|
announceSections(response.data.map((section) => ({ id: section.id, name: section.name })));
|
||||||
if (response.steps) setSteps(response.steps);
|
if (response.steps) setSteps(response.steps);
|
||||||
})
|
})
|
||||||
.catch((failure: unknown) => setError(readableError(failure)));
|
.catch((failure: unknown) => setError(readableError(failure)));
|
||||||
|
|||||||
@@ -60,3 +60,32 @@ export const pageFromParam = (pages: Page[] | null, value: string | null): Page
|
|||||||
if (!pages || !value) return null;
|
if (!pages || !value) return null;
|
||||||
return pages.find((page) => pageParam(page) === value) ?? null;
|
return pages.find((page) => pageParam(page) === value) ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected page's sections, shared between the builder and the nav.
|
||||||
|
*
|
||||||
|
* ── Announced, not fetched twice ──
|
||||||
|
*
|
||||||
|
* The page builder already reads them — it needs their names and columns to know what it is looking at
|
||||||
|
* in the rendered document — and the nav draws them as rows under the open page. Two fetchers would
|
||||||
|
* agree until somebody renamed a band.
|
||||||
|
*
|
||||||
|
* The builder mounts whenever a page is selected, and the nav only draws these under the page that is
|
||||||
|
* selected, so there is no state where the nav wants them and nothing has spoken.
|
||||||
|
*
|
||||||
|
* Null means nobody has said yet: the nav then draws no rows rather than an empty list, which would
|
||||||
|
* read as "this page has no sections".
|
||||||
|
*/
|
||||||
|
export type NamedSection = { id: number; name: string };
|
||||||
|
|
||||||
|
const useSectionsList = defineChannel<NamedSection[] | null>('fofos-admin:sections', null);
|
||||||
|
|
||||||
|
export const useSections = () => {
|
||||||
|
const [sections, setSections] = useSectionsList();
|
||||||
|
|
||||||
|
const setRef = useRef(setSections);
|
||||||
|
setRef.current = setSections;
|
||||||
|
const announceSections = useCallback((rows: NamedSection[] | null) => setRef.current(rows), []);
|
||||||
|
|
||||||
|
return { sections, announceSections };
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user