Add component asks what kind, even with one to choose

It opened the text editor straight away, and I had argued for that: Free Text is the only kind that
works, so a picker offering it is a click asking to be got past.

That was wrong in the way a shortcut usually is. It optimised the state of the app today and hid its
shape — somebody adding a component could not 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, because a list of only what is finished gives no sense of what the thing is for. The same
argument applied here and I had not noticed.

Image, Video and Gallery are named and disabled. Those three are things the site already HAS — media,
video and galleries all exist as concepts here — which is the difference between saying what is coming
and inventing a roadmap.

The list lives in the plugin rather than the API. The API decides what may be STORED; this decides what
may be OFFERED, and it has to be 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:45:21 +01:00
co-authored by Claude Opus 5
parent 79b879f3f4
commit 8ab0a4a516
2 changed files with 110 additions and 5 deletions
+92
View File
@@ -0,0 +1,92 @@
import { Images, Type, Video, 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';
type Choice = {
kind: ComponentKind | null;
label: string;
hint: string;
Icon: LucideIcon;
};
/**
* `kind: null` means not built.
*
* Named rather than omitted, and the three 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.
*/
const CHOICES: Choice[] = [
{ kind: 'free-text', label: 'Text', hint: 'Words, headings and links', Icon: Type },
{ kind: null, label: 'Image', hint: 'One picture from the media store', Icon: Images },
{ kind: null, label: 'Video', hint: 'A video, with a poster', Icon: Video },
{ kind: null, label: 'Gallery', hint: 'A named set of media', Icon: Images },
];
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>
);
+18 -5
View File
@@ -7,6 +7,7 @@ import { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextDialog } from './FreeTextDialog';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { pageFromParam, usePages, type Page } from '../usePages';
/**
@@ -60,6 +61,8 @@ export const PageBuilderPanel = () => {
}>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} });
const [adding, setAdding] = useState(false);
const [creating, setCreating] = useState(false);
/** Where a component is about to be added, while the kind is being chosen. */
const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null);
/** The component being written, and where a new one would go. Null when the dialog is shut. */
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
null,
@@ -177,14 +180,22 @@ export const PageBuilderPanel = () => {
// Assigned on every render, after both functions above exist, so the ref always holds the current
// pair without either being part of `onDocument`'s identity.
/*
Free Text is the only kind, so Add component opens the editor straight away.
Add component asks WHAT, even though only one kind can be chosen today.
No picker in between: one option is not a choice, and a dialog that offers it is a click asking to
be got past. The picker arrives with the second kind, which is the moment it starts meaning
something.
It opened the text editor straight away, on the reasoning that one option is not a choice. That
optimised the state of the app rather than its shape: somebody adding a component could not see what
a component is or that there will be others, and the day the second kind lands the interaction
changes underneath them. The picker names the ones that are coming, the way the nav does.
*/
const addComponent = (section: CanvasSection, column: number) =>
setEditing({ id: null, sectionId: section.id, column, html: '' });
setChoosing({ sectionId: section.id, column });
const chooseKind = (kind: ComponentKind) => {
if (!choosing) return;
setChoosing(null);
// Only one kind reaches here today. When there are more, this is where each one's editor opens.
if (kind === 'free-text') setEditing({ id: null, ...choosing, html: '' });
};
const editComponent = (component: CanvasComponent) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
@@ -263,6 +274,8 @@ export const PageBuilderPanel = () => {
onCreate={(values) => void createSection(values)}
/>
<AddComponentDialog open={choosing !== null} onCancel={() => setChoosing(null)} onChoose={chooseKind} />
<FreeTextDialog
open={editing !== null}
busy={creating}