The section is real, and the nav draws the site's pages as rows beneath it. That is the one place this admin has a hierarchy and it earns it: a page is a thing you go to, and the list of them is the site. Drawn whether or not Pages is the open section — hiding the list until you click makes the nav a menu of menus. Three kinds of change, saved three different ways, and the split is deliberate. TEXT — a title, a menu label, an address — is drafted and written by the one Save in the bar, because typing is something people do halfway and change their minds about. ADDING, DELETING and REORDERING are immediate: each is a single deliberate gesture with an obvious result, and holding them in a draft would put a list on screen that does not exist yet, then leave Save to decide what order to apply three kinds of change in. Only the rows that actually changed are sent, so a save touches what somebody edited and nothing else. A failed save reloads from the server rather than leaving the typed values on screen, where they would look saved. Home's address and delete button are ABSENT, not disabled. A greyed control invites somebody to work out why they cannot use it; there is nothing to work out, the front page is always `/`. The two panels share the list through a channel rather than both fetching it. They are siblings and neither can pass the other a prop, and two fetches means two lists that agree until somebody renames a page. Selection stays in the URL — `?page=` — because that is addressable and the list is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
9.1 KiB
TypeScript
223 lines
9.1 KiB
TypeScript
import { useSearchParams } from 'react-router';
|
|
import { Check, Eye, Loader2, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DEFAULT_SECTION, isSectionKey, 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 { PublishButton } from './PublishButton';
|
|
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
|
|
import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode';
|
|
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>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Editing, or just looking.
|
|
*
|
|
* Two buttons rather than one that toggles, so the current state is readable without knowing what the
|
|
* label means. A single button saying "View" has to be interpreted — is that what it is, or what it
|
|
* would become? — and that question is asked every time.
|
|
*/
|
|
const ModeToggle = () => {
|
|
const { previewable, mode, setMode } = useViewMode();
|
|
if (!previewable) return null;
|
|
|
|
const options: { value: Mode; label: string; Icon: typeof Pencil }[] = [
|
|
{ value: 'edit', label: 'Edit', Icon: Pencil },
|
|
{ value: 'view', label: 'View', Icon: Eye },
|
|
];
|
|
|
|
return (
|
|
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
|
|
{options.map(({ value, label, Icon }) => (
|
|
<Button
|
|
key={value}
|
|
size="sm"
|
|
variant={mode === value ? 'secondary' : 'ghost'}
|
|
className="h-5 gap-1 px-2 text-xs"
|
|
onClick={() => setMode(value)}
|
|
>
|
|
<Icon className="size-3" />
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** The width the preview is rendered at. See DEVICES for why these three and not others. */
|
|
const DeviceToggle = () => {
|
|
const { previewable, device, setDevice } = useViewMode();
|
|
if (!previewable) return null;
|
|
|
|
const options: { value: DeviceName; Icon: typeof Monitor }[] = [
|
|
{ value: 'desktop', Icon: Monitor },
|
|
{ value: 'tablet', Icon: Tablet },
|
|
{ value: 'mobile', Icon: Smartphone },
|
|
];
|
|
|
|
return (
|
|
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
|
|
{options.map(({ value, Icon }) => (
|
|
<Button
|
|
key={value}
|
|
size="sm"
|
|
variant={device === value ? 'secondary' : 'ghost'}
|
|
className="h-5 gap-1 px-2 text-xs"
|
|
onClick={() => setDevice(value)}
|
|
// The width is in the tooltip rather than the label. It is the thing somebody occasionally
|
|
// needs and never needs three of at once, and three widths in a row is a row of numbers.
|
|
title={`${DEVICES[value].label} — ${DEVICES[value].width}px`}
|
|
>
|
|
<Icon className="size-3" />
|
|
<span className="sr-only">{DEVICES[value].label}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const DetailPanel = () => {
|
|
const [params] = useSearchParams();
|
|
const raw = params.get('section');
|
|
const selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
|
|
|
|
const { ready, rootSet, loading } = useReady();
|
|
|
|
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.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">
|
|
<div className="flex flex-1 justify-start">
|
|
<ModeToggle />
|
|
</div>
|
|
|
|
<div className="flex justify-center">
|
|
<DeviceToggle />
|
|
</div>
|
|
|
|
<div className="flex flex-1 items-center justify-end gap-2">
|
|
{/* Left of Publish, and in that order for a reason: save what you wrote, then make it
|
|
public. A row that put them the other way round would read as the sequence backwards. */}
|
|
<SaveButton />
|
|
<PublishButton />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
|
|
</ViewModeProvider>
|
|
</SaveTargetProvider>
|
|
</div>
|
|
);
|
|
};
|