`?page=about` instead of `?page=2`. A URL that names the page can be read, sent to somebody, and still means the right thing after a database is restored from a dump with different ids — which happened once in this project already, earlier today. Home needs a word, because its slug is empty by design: that is the check constraint, and it is what makes `/` fall out of joining a slug to the root. An empty string is no use in a query parameter that has to name a page unambiguously, so the admin says `home` — and the site's API now REFUSES that slug, which is what makes the mapping total in both directions rather than merely usual. One reserved address costs nothing. Anybody who wanted /home wanted the front page, which they have. One place maps a page to its URL value and back — pageParam and pageFromParam — so the nav, the detail panel and the builder cannot disagree about what `?page=` means. `record` is deleted on every navigation. It belonged to the per-page record panels, which are unreachable now; nothing reads it, so it was not a bug, but every URL anybody copied carried a parameter naming a row in a section that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
9.6 KiB
TypeScript
233 lines
9.6 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 { PageBuilderPanel } from './sections/PageBuilderPanel';
|
|
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');
|
|
/*
|
|
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. `?page=` is what distinguishes the two, which keeps both addressable.
|
|
*/
|
|
const selectedPage = params.get('page');
|
|
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.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">
|
|
<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>
|
|
);
|
|
};
|