Files
fofos-admin/web/sections/AddComponentDialog.tsx
pastilhasandClaude Opus 5 b7455c0470 contain the pickers and let their lists scroll
The carousel list ran off the bottom of the window with no way to reach the end
of it. There are twenty-odd carousels now — three sections' worth — where there
were six when the dialog was written.

`max-h-[80svh]` on the dialog and `min-h-0 overflow-y-auto` on the list. Both are
needed and the second is the one that is easy to miss: a flex child will not
shrink below its content without `min-h-0`, so the list pushes the dialog past
the screen and the overflow never engages. A max-height alone would have looked
like it did nothing.

`svh` rather than `vh`, because on a phone `vh` is the viewport with the browser
chrome hidden — a dialog sized in it is taller than what you can see.

AddComponentDialog gets the same treatment. Its list is a dozen kinds now and was
the same failure waiting for a shorter screen.

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

176 lines
8.0 KiB
TypeScript

import { Handshake, Heading, Image, Images, LayoutGrid, Mail, Ruler, Type, type LucideIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
/**
* Choosing what to put in a column.
*
* ── One option is still a choice worth showing ──
*
* I had argued the opposite: Free Text is the only kind that works, so opening the editor straight away
* saves a click that asks to be got past. That was wrong in the way a shortcut usually is — it optimised
* the state of the app today and hid the shape of it. Somebody adding a component cannot see what a
* component IS, or that there will be others, and the day the second kind lands the interaction changes
* underneath them.
*
* It is also the pattern this admin already uses. The nav lists unbuilt sections as `soon` rather than
* hiding them, on the reasoning that a nav showing only what is finished gives no sense of what the
* thing is for. The same argument applies here.
*
* ── The list is in the plugin, not the API ──
*
* The API decides what may be STORED — `free-text`, today. This decides what may be OFFERED, and it has
* to live here because every kind needs a label, an icon and an editor that only exists in this plugin.
* A kind the server would accept and this has never heard of is not creatable anyway.
*/
export type ComponentKind =
| 'free-text'
| 'media'
| 'carousel'
| 'contact-form'
| 'partners'
| 'collections'
| 'pieces'
| 'collabs'
| 'subject-cover'
| 'subject-title'
| 'subject-description'
| 'subject-specs'
| 'subject-carousel';
/*
The blocks that draw whatever the page is ABOUT.
Only offered on a template. On an ordinary page they would render nothing, which is the worst kind of
block: one that is available, adds cleanly, and silently does nothing.
*/
export const SUBJECT_KINDS: ComponentKind[] = [
'subject-cover',
'subject-title',
'subject-description',
'subject-specs',
'subject-carousel',
];
type Choice = {
kind: ComponentKind | null;
label: string;
hint: string;
Icon: LucideIcon;
};
/**
* `kind: null` means not built.
*
* Named rather than omitted, and the ones named are things the site already HAS — media, video and
* carousels all exist as concepts here. That is the difference between saying what is coming and
* inventing a roadmap. The carousel stopped being one of them on 2026-08-31: the set, its ordering and
* carousel that draws it were all already built, so the block is a reference to one.
*/
export const CHOICES: Choice[] = [
{ kind: 'free-text', label: 'Text', hint: 'Words, headings and links', Icon: Type },
/*
One entry, either kind, because the block does not store which.
Image and Video were two rows — one of them greyed out as `soon` — and the distinction was never
the editor's to make: the path names a real file and what that file IS is a fact about it. So the
picker asks for a file and the site works out the rest, which is also what a carousel item has
always done.
*/
{ kind: 'media', label: 'Media', hint: 'A photograph or a video, from the media store', Icon: Image },
{ kind: 'carousel', label: 'Carousel', hint: 'A named set of media', Icon: Images },
/*
Three that place something the site already draws.
Nothing to choose and nothing to type, so adding one is a single click and there is no editor
behind it. They are here rather than hidden because a page is built by picking blocks, and a block
somebody cannot find is a block that does not exist.
`collections` is the first whose content is a QUERY rather than a shared list somebody maintains —
it holds nothing at all, so a collection added tomorrow appears wherever one of these sits without
anybody opening a page. The hint says "every" for that reason: it is not a selection.
*/
{ kind: 'contact-form', label: 'Contact form', hint: 'The site\'s form, as it is', Icon: Mail },
{ kind: 'partners', label: 'Partners', hint: 'The partner logos, from Site', Icon: Handshake },
{ kind: 'collections', label: 'Collections', hint: 'Every collection, as a grid', Icon: LayoutGrid },
{ kind: 'pieces', label: 'Pieces', hint: 'Every gallery piece, as a grid', Icon: LayoutGrid },
{ kind: 'collabs', label: 'Collabs', hint: 'Every collaboration, as a grid', Icon: LayoutGrid },
/*
The five that draw whatever the page is ABOUT, on a template.
They hold nothing — not even a reference. The subject is the URL, so one block draws a different
collection on every request, and that is what lets one arrangement serve all of them.
Called `subject-` rather than `collection-`: the same five draw a gallery piece, and the labels say
"it" for that reason. What differs between the sections is a path and a few spec labels, and both
travel with the subject.
*/
{ kind: 'subject-cover', label: 'Its lead photograph', hint: 'The first in its carousel, large', Icon: Image },
{ kind: 'subject-title', label: 'Its name', hint: 'What this page is about', Icon: Heading },
{ kind: 'subject-description', label: 'Its description', hint: 'What was written about it', Icon: Type },
{ kind: 'subject-specs', label: 'Its specs', hint: 'Measurement, material, colours, and the rest', Icon: Ruler },
{ kind: 'subject-carousel', label: 'Its photographs', hint: 'The carousel it points at', Icon: Images },
];
export const AddComponentDialog = ({
open,
onCancel,
onChoose,
template,
}: {
open: boolean;
onCancel: () => void;
onChoose: (kind: ComponentKind) => void;
/**
* Whether this page is a TEMPLATE, which decides whether the subject blocks are offered.
*
* Hidden rather than disabled elsewhere. A greyed-out row invites the question "why not here", and
* the answer — this page has no subject — is not something a tooltip fixes. On a page that has one
* they are the point; on any other they would render nothing at all.
*/
template?: boolean;
}) => (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
{/* Bounded and scrolling, for the same reason CarouselPicker is: the list of kinds has grown to a
dozen and would otherwise run off a short screen with no way to reach the end. `min-h-0` on the
grid is what lets it shrink and the overflow engage. */}
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add a component</DialogTitle>
</DialogHeader>
<div className="-mr-2 grid min-h-0 flex-1 grid-cols-2 gap-2 overflow-y-auto pr-2">
{/* `kind === null` is a choice that is not built yet — never a subject block, and it must not
be filtered out by a list that only knows about the ones that are. */}
{CHOICES.filter((choice) => template || choice.kind === null || !SUBJECT_KINDS.includes(choice.kind)).map(
({ kind, label, hint, Icon }) => (
<button
key={label}
type="button"
disabled={kind === null}
onClick={() => kind && onChoose(kind)}
className={`flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors ${
kind === null
? 'cursor-not-allowed border-border opacity-45'
: 'border-border hover:border-primary hover:bg-muted/50'
}`}
>
<span className="flex items-baseline gap-2">
<Icon className="size-4 shrink-0 self-center text-muted-foreground" />
<span className="text-sm">{label}</span>
{/* Dimmed and labelled rather than hidden, the same way the nav marks a section that is
not built. A list of only what is finished gives no sense of what this is for. */}
{kind === null && (
<span className="text-[10px] tracking-wide text-muted-foreground/70 uppercase">soon</span>
)}
</span>
<span className="text-[11px] text-muted-foreground">{hint}</span>
</button>
),
)}
</div>
</DialogContent>
</Dialog>
);