A fourth level under the open band only — a page of six bands with four blocks each is thirty rows, and the nav stops being somewhere you can find anything. Blocks are named by the same list the picker offers, so a thing is called the same word wherever it appears. Clicking a block in the PAGE now writes that address too, rather than calling the handler directly. Both ways of picking a block are the same act, so neither can disagree with the other about what is selected — and closing it is the band row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
4.8 KiB
TypeScript
110 lines
4.8 KiB
TypeScript
import { Handshake, Image, Images, Mail, 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';
|
|
|
|
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 },
|
|
/*
|
|
Two 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.
|
|
*/
|
|
{ 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 },
|
|
];
|
|
|
|
export const AddComponentDialog = ({
|
|
open,
|
|
onCancel,
|
|
onChoose,
|
|
}: {
|
|
open: boolean;
|
|
onCancel: () => void;
|
|
onChoose: (kind: ComponentKind) => void;
|
|
}) => (
|
|
<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.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>
|
|
);
|