`collection-cover` draws the FIRST photograph in the gallery, large, beside the words — the shape the client's sitemap draws. Not a second way to choose one: a gallery is ordered and reordering it is how the lead changes, which is the rule everywhere else here. The five subject blocks are places-only, like the contact form and the partner row. They hold nothing at all — what they draw is the collection the URL names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
7.0 KiB
TypeScript
163 lines
7.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'
|
|
| 'gallery'
|
|
| 'contact-form'
|
|
| 'partners'
|
|
| 'collections'
|
|
| 'collection-cover'
|
|
| 'collection-title'
|
|
| 'collection-description'
|
|
| 'collection-specs'
|
|
| 'collection-gallery';
|
|
|
|
/*
|
|
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[] = [
|
|
'collection-cover',
|
|
'collection-title',
|
|
'collection-description',
|
|
'collection-specs',
|
|
'collection-gallery',
|
|
];
|
|
|
|
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
|
|
* galleries all exist as concepts here. That is the difference between saying what is coming and
|
|
* inventing a roadmap. Gallery stopped being one of them on 2026-08-31: the set, its ordering and the
|
|
* 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 gallery item has
|
|
always done.
|
|
*/
|
|
{ kind: 'media', label: 'Media', hint: 'A photograph or a video, from the media store', Icon: Image },
|
|
{ kind: 'gallery', label: 'Gallery', 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 },
|
|
|
|
/*
|
|
The four that draw THIS collection, on the 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.
|
|
*/
|
|
{ kind: 'collection-cover', label: 'Its lead photograph', hint: 'The first in its gallery, large', Icon: Image },
|
|
{ kind: 'collection-title', label: 'Its name', hint: 'The collection this page is about', Icon: Heading },
|
|
{ kind: 'collection-description', label: 'Its description', hint: 'What was written about it', Icon: Type },
|
|
{ kind: 'collection-specs', label: 'Its specs', hint: 'Measurement, material, colours, delivery', Icon: Ruler },
|
|
{ kind: 'collection-gallery', label: 'Its photographs', hint: 'The gallery 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()}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Add a component</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{CHOICES.filter((choice) => template || !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>
|
|
);
|