Files
fofos-admin/web/DetailPanel.tsx
T
pastilhasandClaude Opus 5 b5ca67e68e every control writes itself
The Save button is gone, and so is the SaveTarget context that announced it.
Every screen here is a preview of the thing being edited, so a change is already
visible before it is written — a button then asks somebody to confirm what they
can see.

useAutosave debounces a burst into one request. It takes the unsaved state as a
string rather than a flag, so a failed save is not retried on the next render,
and it flushes on unmount so a section change loses nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:34:34 +01:00

163 lines
7.7 KiB
TypeScript

import { Navigate } from 'react-router';
import { href, useNav } from './routes';
import { DEFAULT_SECTION } from './sections';
import { Eye, 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 { 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 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.
*/}
<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: make the media web-ready, then make it public. 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.
No Save. Every control writes itself — see useAutosave. */}
<SyncButton />
<PublishButton />
</div>
</div>
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
</ViewModeProvider>
</div>
);
};