Files
fofos-admin/web/DetailPanel.tsx
T
pastilhasandClaude Opus 5 abdb519e8b one Save, in the panel's bar
Every section had its own Save buttons — four on About, two on Home, one per record — and on a page
long enough to scroll, the button you wanted was the one that had just left the screen.

Now there is one, beside Publish, pinned above the scroll. The section announces whether it has unsaved
changes, whether it is busy, and how to save; the bar renders that. Neither knows what the other is,
which is what keeps DetailPanel a map of keys to components rather than a place that has to know what
each section can do.

── What this replaces ──

The per-card saves existed because the API treats an absent key as "leave it alone", so a Save carrying
everything meant a half-written manifesto shipping with a photograph somebody meant to change. With one
Save that is the model rather than an accident: one press writes the page you are looking at.

The cost, stated plainly: there is no longer a way to commit one part of a page and leave the rest.
Nothing here needed that, and if something ever does it wants its own verb rather than four buttons all
called Save.

── Dirty is a comparison, not a flag ──

Against what the server last returned, field by field. A flag says "something was typed" and stays true
after you undo it by hand; a comparison says "this differs from what is stored", which is the question
a Save button is actually answering — and it is what lets the button be dead when there is nothing to
do, which is the whole value of having one.

The gallery picker on Home is deliberately outside it and still writes on selection: a <select> that
needs a second press to mean anything is a control people believe they have already used.

Sections with nothing to save render no button rather than a dead one. A permanently disabled control
invites a press and teaches that pressing does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:22:35 +01:00

146 lines
6.5 KiB
TypeScript

import { useSearchParams } from 'react-router';
import { Check, Loader2 } 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 { HomePanel } from './sections/HomePanel';
import { GalleriesPanel } from './sections/GalleriesPanel';
import { PublishButton } from './PublishButton';
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
import { AboutPanel } from './sections/AboutPanel';
import { RecordsPanel } from './sections/RecordsPanel';
import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records';
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>> = {
home: HomePanel,
about: AboutPanel,
/*
Three sections, one panel, configured — the same split the site's API already makes between
`records.ts` and `sections.ts`. See web/sections/records.ts for what each one carries.
*/
gallery: () => <RecordsPanel config={GALLERY_CONFIG} />,
drops: () => <RecordsPanel config={DROPS_CONFIG} />,
collabs: () => <RecordsPanel config={COLLABS_CONFIG} />,
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>
);
};
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>
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-border px-4 py-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 className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
</SaveTargetProvider>
</div>
);
};