The admin declares its routes in the manifest, so /fofos-admin/layout/header and /fofos-admin/pages/home are real URLs. One reader takes the two segments after the mount; links carry no query string at all, so nothing leaks between sections. What stays in the query is how you are looking — device, tab, view — rather than at what. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
197 lines
8.9 KiB
TypeScript
197 lines
8.9 KiB
TypeScript
import { Navigate } from 'react-router';
|
|
import { href, useNav } from './routes';
|
|
import { DEFAULT_SECTION } from './sections';
|
|
import { Check, Eye, Loader2, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
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 { PublishButton } from './PublishButton';
|
|
import { SyncButton } from './SyncButton';
|
|
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
|
|
import { DEVICES, ViewModeProvider, useViewMode } from './ViewMode';
|
|
import { DeviceToggle } from './DeviceToggle';
|
|
import { MessagesPanel } from './MessagesPanel';
|
|
import { PlannedSection } from './PlannedSection';
|
|
import { useReady } from './useReady';
|
|
|
|
/**
|
|
* The right column: whichever section the nav has selected.
|
|
*
|
|
* It reads `?section=` from the URL rather than being handed it — the two panels are siblings in a
|
|
* workspace and neither owns the other.
|
|
*
|
|
* ── There used to be a `?config=1` ──
|
|
*
|
|
* The connection form was an overlay reached from a link in this header, because it was not one of the
|
|
* sections. Now it IS one — Administration — so the overlay is gone and so is the link. A second route
|
|
* to the same screen is a second thing to keep correct, and the two disagreed the moment a section was
|
|
* selected underneath a form that covered it.
|
|
*
|
|
* ── Not ready means Administration, whatever the URL says ──
|
|
*
|
|
* The gate is `useReady`, shared with the nav so the two cannot disagree. Enforced HERE and not only
|
|
* there, because the nav can only decline to offer a link — `?section=gallery` typed into the address
|
|
* bar, or a bookmark from before the connection was forgotten, arrives without going through it. A
|
|
* nav that hides a door is a courtesy; this is the lock.
|
|
*/
|
|
|
|
/**
|
|
* Which component renders which section.
|
|
*
|
|
* A map rather than a conditional on `built`. The version of this that read
|
|
* `built ? <MessagesPanel/> : <Planned/>` was correct only while exactly one section existed — the
|
|
* second one to be finished would have rendered Messages under its own name, which is the kind of bug
|
|
* that looks like a data problem for an hour. A key with no entry here falls through to Planned, so
|
|
* marking a section `built` in sections.ts without writing its panel fails visibly rather than
|
|
* silently showing somebody else's.
|
|
*/
|
|
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
|
layout: LayoutPanel,
|
|
pages: PagesPanel,
|
|
galleries: GalleriesPanel,
|
|
messages: MessagesPanel,
|
|
administration: AdministrationPanel,
|
|
media: MediaPanel,
|
|
};
|
|
|
|
/**
|
|
* The one Save, driven by whichever section is open.
|
|
*
|
|
* Renders nothing at all when the section offers none — Media, Administration and Messages have
|
|
* nothing to save, and a permanently dead button is worse than no button: it invites a press and
|
|
* teaches that pressing does nothing.
|
|
*/
|
|
const SaveButton = () => {
|
|
const target = useSaveButton();
|
|
if (!target) return null;
|
|
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 gap-1.5 px-2 text-xs"
|
|
onClick={() => void target.save()}
|
|
// Dead until there is something to write. The whole value of one Save is that its state answers
|
|
// "have I saved?" — a button that looks the same either way answers nothing.
|
|
disabled={!target.dirty || target.busy}
|
|
title={target.dirty ? 'Save your changes' : 'Nothing to save'}
|
|
>
|
|
{target.busy ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
|
{target.busy ? 'Saving…' : 'Save'}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
|
|
/** The width the preview is rendered at. See DEVICES for why these three and not others. */
|
|
const BarDeviceToggle = () => {
|
|
const { previewable } = useViewMode();
|
|
if (!previewable) return null;
|
|
|
|
// The same control the editor sections use — see DeviceToggle, which reads `?device=` rather than a
|
|
// context so every panel on the screen shares one answer.
|
|
return <DeviceToggle size="bar" />;
|
|
};
|
|
|
|
|
|
export const DetailPanel = () => {
|
|
/*
|
|
A page selected inside Pages opens the BUILDER, not the list with a row lit up. By SLUG, so the URL
|
|
says which page rather than which row — see pageParam in usePages.
|
|
|
|
The nav says the same thing — Pages is a section and the pages are rows under it — so the detail
|
|
panel follows it. The second path segment is what distinguishes the two, which keeps both
|
|
addressable: `/fofos-admin/pages` is the list, `/fofos-admin/pages/home` is that page.
|
|
*/
|
|
const { section: selected, item: selectedPage, atIndex } = useNav();
|
|
|
|
const { ready, rootSet, loading } = useReady();
|
|
|
|
/*
|
|
The bare `/fofos-admin` redirected to its first section, once, replacing.
|
|
|
|
The screen has to render SOMETHING there, and rendering Layout while the address bar says nothing
|
|
means the URL stops describing what is on screen — which is the whole reason the sections moved out
|
|
of the query string. `replace`, so Back leaves the admin rather than bouncing off the redirect.
|
|
*/
|
|
if (atIndex) return <Navigate to={href(DEFAULT_SECTION)} replace />;
|
|
|
|
if (loading) {
|
|
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
|
|
}
|
|
|
|
/*
|
|
Not ready, so Administration regardless of what the URL asked for. The header then NAMES it, which
|
|
is the part the old overlay could not do: the form used to appear under whichever row happened to
|
|
be highlighted, so it read as that section being broken rather than as one step not done yet.
|
|
*/
|
|
/*
|
|
A section that cannot be opened sends you to Administration, because that is where every requirement
|
|
is satisfied. Asked per section rather than by one flag: Media is reachable the moment a root
|
|
location exists, long before fofos is connected.
|
|
*/
|
|
const wanted = sectionFor(selected);
|
|
const current = sectionOpen(wanted, { ready, rootSet }) ? wanted : sectionFor('administration');
|
|
|
|
const Panel =
|
|
current.key === 'pages' && selectedPage ? PageBuilderPanel : current.built ? PANELS[current.key] : undefined;
|
|
|
|
return (
|
|
<div className="flex size-full flex-col overflow-hidden">
|
|
{/*
|
|
The site's own actions, pinned above whatever the section is showing.
|
|
|
|
OUTSIDE the scroll area on purpose. Every panel scrolls its own contents, so anything drawn
|
|
inside one leaves the screen as soon as somebody reads to the bottom — and Publish is what you
|
|
reach for AFTER reading to the bottom. It sits here instead, as a sibling of the scrolling
|
|
region rather than the first thing inside it.
|
|
|
|
Not in the workspace's chrome header either, where it lived for an afternoon. That header is
|
|
shared furniture drawn the same way by every panel in the app; these two buttons belong to this
|
|
plugin and to the site it administers.
|
|
*/}
|
|
<SaveTargetProvider>
|
|
<ViewModeProvider>
|
|
{/*
|
|
Three groups: how you are looking at it, at what width, and what to do with it.
|
|
|
|
The middle one is centred against the BAR rather than against what is beside it — the two
|
|
outer groups change width as sections come and go, and a middle that drifted with them
|
|
would never be in the same place twice.
|
|
*/}
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
|
|
{/* Empty, and deliberately still here: the middle group is centred against the BAR, so
|
|
removing the left group would let Desktop/Tablet/Mobile drift left as the right group
|
|
changes width. It held Edit/View — see the note in ViewMode about why there is no
|
|
longer a mode to toggle. */}
|
|
<div className="flex flex-1 justify-start" />
|
|
|
|
<div className="flex justify-center">
|
|
<BarDeviceToggle />
|
|
</div>
|
|
|
|
<div className="flex flex-1 items-center justify-end gap-2">
|
|
{/* The order is the sequence: save what you wrote, make the media web-ready, then make
|
|
it public. A row that put them any other way round would read as the steps backwards.
|
|
|
|
Sync sits left of Build for the same reason Build sits left of Publish — it is what
|
|
has to have happened first, and a stale `derived/` is a picture missing from the page
|
|
you just built. */}
|
|
<SaveButton />
|
|
<SyncButton />
|
|
<PublishButton />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
|
|
</ViewModeProvider>
|
|
</SaveTargetProvider>
|
|
</div>
|
|
);
|
|
};
|