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:
@@ -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>
|
||||||
|
);
|
||||||
@@ -7,6 +7,7 @@ import { usePreviewable, useViewMode } from '../ViewMode';
|
|||||||
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
|
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
|
||||||
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
||||||
import { FreeTextDialog } from './FreeTextDialog';
|
import { FreeTextDialog } from './FreeTextDialog';
|
||||||
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
||||||
import { pageFromParam, usePages, type Page } from '../usePages';
|
import { pageFromParam, usePages, type Page } from '../usePages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +61,8 @@ export const PageBuilderPanel = () => {
|
|||||||
}>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} });
|
}>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} });
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [creating, setCreating] = 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. */
|
/** 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>(
|
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
|
||||||
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
|
// 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.
|
// 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
|
It opened the text editor straight away, on the reasoning that one option is not a choice. That
|
||||||
be got past. The picker arrives with the second kind, which is the moment it starts meaning
|
optimised the state of the app rather than its shape: somebody adding a component could not see what
|
||||||
something.
|
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) =>
|
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 editComponent = (component: CanvasComponent) => {
|
||||||
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
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)}
|
onCreate={(values) => void createSection(values)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddComponentDialog open={choosing !== null} onCancel={() => setChoosing(null)} onChoose={chooseKind} />
|
||||||
|
|
||||||
<FreeTextDialog
|
<FreeTextDialog
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
busy={creating}
|
busy={creating}
|
||||||
|
|||||||
Reference in New Issue
Block a user