diff --git a/web/sections/AddComponentDialog.tsx b/web/sections/AddComponentDialog.tsx new file mode 100644 index 0000000..f90470e --- /dev/null +++ b/web/sections/AddComponentDialog.tsx @@ -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; +}) => ( + !next && onCancel()}> + + + Add a component + + +
+ {CHOICES.map(({ kind, label, hint, Icon }) => ( + + ))} +
+
+
+); diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index a55a0d5..1061299 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -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)} /> + setChoosing(null)} onChoose={chooseKind} /> +