From 4b3af5030ac46e9ac2429bc1a7087d2e51102a70 Mon Sep 17 00:00:00 2001 From: Tim Bartrum Date: Mon, 7 Sep 2026 11:22:45 +0100 Subject: [PATCH 1/7] fix(deps): @m10c/mui-kit 0.0.2 for its root export `import { FieldText } from '@m10c/mui-kit'` cannot resolve against 0.0.1, which exports only subpaths, so `yarn ts` and the dts build fail on a fresh checkout. `^0.0.1` pins that version exactly, being a 0.0.x range. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b99e6be..00a7f12 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "eslint": "^9", "eslint-config-prettier": "^9", "eslint-plugin-react-hooks": "^5", - "@m10c/mui-kit": "^0.0.1", + "@m10c/mui-kit": "^0.0.2", "prettier": "^3.5.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/yarn.lock b/yarn.lock index a5cd40f..417d7fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -449,10 +449,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@m10c/mui-kit@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@m10c/mui-kit/-/mui-kit-0.0.1.tgz#fe00038afc166a644b323fc283e08b4a9b462d56" - integrity sha512-ZZmGsH6cdyfjbtA4GlicZ8gn3PqzFow2GaRCXIz5U0xXRdAJWmIjFFFIV+/Iyka0TGFmRq3oNSAcQNjA5c4VUA== +"@m10c/mui-kit@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@m10c/mui-kit/-/mui-kit-0.0.2.tgz#0233d1d3e0cdbc1071ede9ed581d059e48a7dfbf" + integrity sha512-9rmyymyqOQ+7UO4di5RqfDE2Bec04W7Id9tM/Ox+vY8ZK778hEwMcZwoK5/kvueOaxUzmlCm96tMGcmi4edvTQ== dependencies: "@phosphor-icons/react" "^2.1.7" From 6db78f95e6cbca9dbec603a5518684501c319322 Mon Sep 17 00:00:00 2001 From: Tim Bartrum Date: Mon, 7 Sep 2026 11:22:55 +0100 Subject: [PATCH 2/7] feat: card list editing, opt-in per field A list field could only edit the items its data already held, with every item's fields listed one after another. Designs for the DCG page editor call for a card per item, added and deleted by the admin, so `variant: 'cards'` renders each item as a summary card with a dialog to edit it, capped by `maxItems` and reordered by dragging. Lists left on the default `inline` render as they always have. Cards need to show a saved value rather than an input, which the package cannot always do: an icon field holds a slug, not a picture. `previews` lets the consumer draw those values, alongside `icons` for the drag and edit affordances, so an app can use its own icon set. Fields carry the extras the designs ask of the schema: `required` marks a label, `hideLabel` drops a heading a block's list already provides, `headerFieldKeys` pulls a sibling field under the list's heading, and `variables` lists the placeholders an admin may type. The preview's device names are overridable too, for sites that call them something else. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/BlocksField.tsx | 453 +++++++++++++++++++++++++++++++-- src/components/PageEditor.tsx | 34 ++- src/components/index.ts | 2 +- src/types.ts | 33 +++ 4 files changed, 493 insertions(+), 29 deletions(-) diff --git a/src/components/BlocksField.tsx b/src/components/BlocksField.tsx index 483760b..4149098 100644 --- a/src/components/BlocksField.tsx +++ b/src/components/BlocksField.tsx @@ -1,12 +1,30 @@ 'use client'; -import { Card, CardContent, Divider, Stack, Typography } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import { + Box, + Button, + Card, + CardContent, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Stack, + Typography, +} from '@mui/material'; import { FieldText } from '@m10c/mui-kit'; import React from 'react'; import { FieldProp } from 'react-typed-form'; import type { Block, + BlockFieldPreviews, BlockFieldRenderer, BlockFieldRenderers, BlockType, @@ -16,13 +34,27 @@ import type { SimpleField, } from '../types'; +/** Icons a consumer can swap for its own icon set. */ +export type ListCardIcons = { + drag?: React.ReactNode; + edit?: React.ReactNode; +}; + type Props = { blockTypes: readonly BlockTypeInput[]; field: FieldProp; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; }; -export default function BlocksField({ blockTypes, field, renderers }: Props) { +export default function BlocksField({ + blockTypes, + field, + renderers, + previews, + icons, +}: Props) { const blocks = field.value ?? []; // The boundary types `fields` as `unknown` (see BlockTypeInput); the BE sends // the rich field metadata, so narrow to BlockType here, the single point of @@ -49,6 +81,8 @@ export default function BlocksField({ blockTypes, field, renderers }: Props) { block={block} blockType={blockTypesByKey[block.type]} renderers={renderers} + previews={previews} + icons={icons} onChange={(next) => updateBlock(index, next)} /> ))} @@ -60,32 +94,81 @@ type BlockCardProps = { block: Block; blockType: BlockType | undefined; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; onChange: (next: Block) => void; }; -function BlockCard({ block, blockType, renderers, onChange }: BlockCardProps) { +function BlockCard({ + block, + blockType, + renderers, + previews, + icons, + onChange, +}: BlockCardProps) { function updateData(key: string, value: unknown) { onChange({ ...block, data: { ...block.data, [key]: value } }); } - return ( - - - - - {blockType?.label ?? `Unknown block: ${block.type}`} - - {blockType ? ( - Object.entries(blockType.fields).map(([key, fieldDef]) => ( - + fieldDef.kind === 'list' ? (fieldDef.headerFieldKeys ?? []) : [], + ), + ); + + /** The fields a list field claims for its heading, rendered in schema order. */ + function renderHeaderFields(fieldDef: BlockTypeField) { + if (fieldDef.kind !== 'list' || !fieldDef.headerFieldKeys?.length) { + return undefined; + } + const claimed = fieldDef.headerFieldKeys; + return ( + <> + {Object.entries(fields) + .filter(([key]) => claimed.includes(key)) + .map(([key, headerFieldDef]) => + headerFieldDef.kind === 'list' ? null : ( + updateData(key, value)} /> - )) + ), + )} + + ); + } + + return ( + + + + {!blockType?.hideLabel && ( + + {blockType?.label ?? `Unknown block: ${block.type}`} + + )} + {blockType ? ( + Object.entries(blockType.fields) + .filter(([key]) => !headerFieldKeys.has(key)) + .map(([key, fieldDef]) => ( + updateData(key, value)} + /> + )) ) : ( No schema registered for block type "{block.type}". @@ -102,6 +185,9 @@ type BlockFieldRendererProps = { fieldDef: BlockTypeField; value: unknown; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + headerSlot?: React.ReactNode; onChange: (value: unknown) => void; }; @@ -110,6 +196,9 @@ function BlockFieldRenderer({ fieldDef, value, renderers, + previews, + icons, + headerSlot, onChange, }: BlockFieldRendererProps) { if (fieldDef.kind === 'list') { @@ -118,6 +207,9 @@ function BlockFieldRenderer({ fieldDef={fieldDef} value={Array.isArray(value) ? value : []} renderers={renderers} + previews={previews} + icons={icons} + headerSlot={headerSlot} onChange={onChange} /> ); @@ -152,7 +244,7 @@ function SimpleFieldRenderer({ onChange, }: SimpleFieldRendererProps) { const stringValue = typeof value === 'string' ? value : null; - const label = labelOverride ?? fieldDef.label ?? fieldKey; + const label = labelOverride ?? fieldLabel(fieldDef, fieldKey); const customRenderer: BlockFieldRenderer | undefined = renderers?.[fieldDef.kind]; @@ -197,27 +289,41 @@ function SimpleFieldRenderer({ ); } +/** A field's label, marked with an asterisk when it is required. */ +function fieldLabel(fieldDef: BlockTypeField, fallback: string) { + return `${fieldDef.label ?? fallback}${fieldDef.required ? '*' : ''}`; +} + type ListItem = Record; type ListFieldRendererProps = { fieldDef: ListField; value: ListItem[]; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + headerSlot?: React.ReactNode; onChange: (value: ListItem[]) => void; }; +function ListFieldRenderer(props: ListFieldRendererProps) { + return props.fieldDef.variant === 'cards' ? ( + + ) : ( + + ); +} + /** - * Renders a fixed list of items inline (count comes from the BE data, no - * add/remove). Each item's fields are shown with an index-suffixed label and - * separated by a divider. + * Lists every item's fields one after another, separated by a divider. The + * number of items comes from the data, so there is nothing to add or delete. */ -function ListFieldRenderer({ +function ListInline({ fieldDef, - value, + value: items, renderers, onChange, }: ListFieldRendererProps) { - const items = value; const itemLabel = fieldDef.itemLabel ?? fieldDef.label ?? 'Item'; function updateItem(index: number, next: ListItem) { @@ -238,7 +344,7 @@ function ListFieldRenderer({ fieldDef={subFieldDef} value={item[subKey]} renderers={renderers} - labelOverride={`${subFieldDef.label ?? subKey} ${itemLabel} ${index + 1}`} + labelOverride={`${fieldLabel(subFieldDef, subKey)} ${itemLabel} ${index + 1}`} onChange={(subValue) => updateItem(index, { ...item, [subKey]: subValue }) } @@ -249,3 +355,304 @@ function ListFieldRenderer({ ); } + +/** + * Shows each item as a summary card that opens a dialog to edit. Items can be + * added until `maxItems` is reached, and deleted while more than `minItems` + * remain. + */ +function ListCards({ + fieldDef, + value: items, + renderers, + previews, + icons, + headerSlot, + onChange, +}: ListFieldRendererProps) { + const [editedIndex, setEditedIndex] = React.useState(null); + const [isAdding, setIsAdding] = React.useState(false); + const [draggedIndex, setDraggedIndex] = React.useState(null); + + const itemLabel = fieldDef.itemLabel ?? 'item'; + const editedItem = editedIndex === null ? undefined : items[editedIndex]; + const { maxItems, minItems } = fieldDef; + const isFull = maxItems !== undefined && items.length >= maxItems; + const canDelete = items.length > (minItems ?? 0); + + function replaceItem(index: number, next: ListItem) { + const updated = items.slice(); + updated[index] = next; + onChange(updated); + } + + function deleteItem(index: number) { + onChange(items.filter((_, i) => i !== index)); + } + + function moveItem(from: number, to: number) { + const updated = items.slice(); + const [moved] = updated.splice(from, 1); + if (moved === undefined) return; + updated.splice(to, 0, moved); + onChange(updated); + } + + return ( + + + + {fieldLabel(fieldDef, itemLabel)} + + + + + {maxItems !== undefined && ( + + {items.length} of {maxItems} {itemLabel}(s) added. You can add up to{' '} + {maxItems} {itemLabel}s. + + )} + + {headerSlot} + + {items.map((item, index) => ( + setEditedIndex(index)} + onDragStart={() => setDraggedIndex(index)} + onDragEnd={() => setDraggedIndex(null)} + onDrop={() => { + if (draggedIndex !== null && draggedIndex !== index) { + moveItem(draggedIndex, index); + } + setDraggedIndex(null); + }} + /> + ))} + + {editedIndex !== null && editedItem !== undefined && ( + { + deleteItem(editedIndex); + setEditedIndex(null); + } + : undefined + } + onConfirm={(next) => { + replaceItem(editedIndex, next); + setEditedIndex(null); + }} + onClose={() => setEditedIndex(null)} + /> + )} + + {isAdding && ( + { + onChange([...items, next]); + setIsAdding(false); + }} + onClose={() => setIsAdding(false)} + /> + )} + + ); +} + +type ListItemCardProps = { + fieldDef: ListField; + item: ListItem; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + onEdit: () => void; + onDragStart: () => void; + onDragEnd: () => void; + onDrop: () => void; +}; + +function ListItemCard({ + fieldDef, + item, + previews, + icons, + onEdit, + onDragStart, + onDragEnd, + onDrop, +}: ListItemCardProps) { + // Only the handle starts a drag, so text inside the card stays selectable. + const [isDraggable, setIsDraggable] = React.useState(false); + + return ( + { + setIsDraggable(false); + onDragEnd(); + }} + onDragOver={(event) => event.preventDefault()} + onDrop={onDrop} + sx={{ p: 2, borderRadius: 1, bgcolor: 'grey.100' }} + > + setIsDraggable(true)} + onMouseUp={() => setIsDraggable(false)} + sx={{ display: 'flex', color: 'primary.main', cursor: 'grab' }} + > + {icons?.drag ?? } + + + {Object.entries(fieldDef.itemFields).map(([subKey, subFieldDef]) => { + const value = typeof item[subKey] === 'string' ? item[subKey] : null; + const preview = previews?.[subFieldDef.kind]; + return ( + + + {fieldLabel(subFieldDef, subKey)} + + {preview ? ( + preview(value) + ) : ( + {value} + )} + + ); + })} + + + {icons?.edit ?? } + + + ); +} + +type ListItemDialogProps = { + title: string; + fieldDef: ListField; + item: ListItem; + renderers?: BlockFieldRenderers; + confirmLabel: string; + onDelete?: () => void; + onConfirm: (item: ListItem) => void; + onClose: () => void; +}; + +function ListItemDialog({ + title, + fieldDef, + item, + renderers, + confirmLabel, + onDelete, + onConfirm, + onClose, +}: ListItemDialogProps) { + const [draft, setDraft] = React.useState(item); + + return ( + + + {title} + + + + + + + {!!fieldDef.variables?.length && ( + + Variables to use + } + > + {fieldDef.variables.map((variable) => ( + + {variable.token} + + {variable.description} + + + ))} + + + )} + {Object.entries(fieldDef.itemFields).map(([subKey, subFieldDef]) => ( + + setDraft({ ...draft, [subKey]: subValue }) + } + /> + ))} + + + + {onDelete ? ( + <> + + + + ) : ( + + )} + + + ); +} diff --git a/src/components/PageEditor.tsx b/src/components/PageEditor.tsx index 9f4c534..d6c560f 100644 --- a/src/components/PageEditor.tsx +++ b/src/components/PageEditor.tsx @@ -18,11 +18,22 @@ import React from 'react'; import { FieldProp } from 'react-typed-form'; import usePreviewSender from '../hooks/use-preview-sender'; -import BlocksField from './BlocksField'; -import type { Block, BlockFieldRenderers, BlockTypeInput } from '../types'; +import BlocksField, { type ListCardIcons } from './BlocksField'; +import type { + Block, + BlockFieldPreviews, + BlockFieldRenderers, + BlockTypeInput, +} from '../types'; type PreviewWidth = 'desktop' | 'tablet' | 'mobile'; +const DEVICE_LABELS: Record = { + desktop: 'Desktop', + tablet: 'Tablet', + mobile: 'Mobile', +}; + const PREVIEW_WIDTHS = { desktop: 1280, tablet: 768, @@ -36,6 +47,10 @@ type Props = { blockTypes: readonly BlockTypeInput[]; field: FieldProp; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + /** Names for the preview's device sizes, e.g. 'Mobile website'. */ + deviceLabels?: Partial>; /** Site origin for the preview iframe and postMessage target. */ previewUrl: string; pagePath: string; @@ -50,6 +65,9 @@ export default function PageEditor({ blockTypes, field, renderers, + previews, + icons, + deviceLabels, previewUrl, pagePath, previewContent, @@ -105,6 +123,8 @@ export default function PageEditor({ blockTypes={blockTypes} field={field} renderers={renderers} + previews={previews} + icons={icons} /> @@ -134,9 +154,13 @@ export default function PageEditor({ IconComponent={KeyboardArrowDown} sx={{ bgcolor: 'background.paper' }} > - Desktop - Tablet - Mobile + {(Object.keys(DEVICE_LABELS) as PreviewWidth[]).map( + (device) => ( + + {deviceLabels?.[device] ?? DEVICE_LABELS[device]} + + ), + )} diff --git a/src/components/index.ts b/src/components/index.ts index 3feaca4..9a69fd6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,4 @@ -export { default as BlocksField } from './BlocksField'; +export { default as BlocksField, type ListCardIcons } from './BlocksField'; export { default as PageEditor } from './PageEditor'; export { default as PublishState } from './PublishState'; export { default as SeoEditor } from './SeoEditor'; diff --git a/src/types.ts b/src/types.ts index 0974ec7..9901116 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ export type SimpleFieldKind = export type SimpleField = { kind: SimpleFieldKind; label?: string; + /** Marks the label with an asterisk. */ + required?: boolean; maxLength?: number; /** Markdown feature flags, e.g. ['bold', 'italic', 'lists', 'links'] */ features?: string[]; @@ -12,9 +14,28 @@ export type SimpleField = { export type ListField = { kind: 'list'; label?: string; + /** Marks the label with an asterisk. */ + required?: boolean; itemLabel?: string; minItems?: number; maxItems?: number; + /** + * `inline` lists every item's fields one after another, and items can only be + * edited. `cards` shows each item as a summary card that opens a dialog to + * edit, and lets an admin add and delete items. + */ + variant?: 'inline' | 'cards'; + /** + * Sibling fields of the same block to render under this list's heading, e.g. + * a subtitle that introduces the items. They are skipped where they would + * otherwise appear in the block. + */ + headerFieldKeys?: string[]; + /** + * Placeholders an admin can type into this list's fields, listed at the top + * of the dialog that edits an item. + */ + variables?: { token: string; description: string }[]; itemFields: Record; }; @@ -23,6 +44,8 @@ export type BlockTypeField = SimpleField | ListField; export type BlockType = { key: string; label: string; + /** Hides the block's heading, for a block whose single field is titled. */ + hideLabel?: boolean; fields: Record; }; @@ -58,3 +81,13 @@ export type BlockFieldRenderer = ( export type BlockFieldRenderers = Partial< Record >; + +/** + * Draws a field's saved value inside a card summary, e.g. the icon a slug + * names. Without one the value is shown as text. + */ +export type BlockFieldPreview = (value: string | null) => React.ReactNode; + +export type BlockFieldPreviews = Partial< + Record +>; From 6ba705c5c17d4656c65d45aeb39db4c472628609 Mon Sep 17 00:00:00 2001 From: Tim Bartrum Date: Mon, 7 Sep 2026 11:23:15 +0100 Subject: [PATCH 3/7] feat: fixed lists, SEO renderers, field formats Three follow-ons from fitting the editor to the DCG designs: A list whose `minItems` equals its `maxItems` cannot grow or shrink, so it drops the heading, the Add button and the count that only make sense where an admin chooses how many items there are. The SEO tab takes `renderers` the way the block editor does, so an app whose inputs look nothing like these can draw its own without the two tabs disagreeing. A renderer is handed the field's `features`, which the schema already carried but nothing passed on: one rich text field offers lists and links where another offers bold. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/BlocksField.tsx | 35 ++++++++++++-------- src/components/SeoEditor.tsx | 59 +++++++++++++++++++++++++--------- src/types.ts | 2 ++ 3 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/components/BlocksField.tsx b/src/components/BlocksField.tsx index 4149098..bf97c11 100644 --- a/src/components/BlocksField.tsx +++ b/src/components/BlocksField.tsx @@ -255,6 +255,7 @@ function SimpleFieldRenderer({ name: fieldKey, label, value: stringValue, + features: fieldDef.features, onChange, })} @@ -379,6 +380,8 @@ function ListCards({ const { maxItems, minItems } = fieldDef; const isFull = maxItems !== undefined && items.length >= maxItems; const canDelete = items.length > (minItems ?? 0); + // A list whose length is fixed has nothing to add or count. + const isFixedLength = minItems !== undefined && minItems === maxItems; function replaceItem(index: number, next: ListItem) { const updated = items.slice(); @@ -400,21 +403,27 @@ function ListCards({ return ( - - - {fieldLabel(fieldDef, itemLabel)} - - - + + {fieldLabel(fieldDef, itemLabel)} + + + + )} - {maxItems !== undefined && ( + {maxItems !== undefined && !isFixedLength && ( {items.length} of {maxItems} {itemLabel}(s) added. You can add up to{' '} {maxItems} {itemLabel}s. diff --git a/src/components/SeoEditor.tsx b/src/components/SeoEditor.tsx index 5cdf140..98fe4d5 100644 --- a/src/components/SeoEditor.tsx +++ b/src/components/SeoEditor.tsx @@ -5,9 +5,13 @@ import { FieldText } from '@m10c/mui-kit'; import type React from 'react'; import type { FieldProp } from 'react-typed-form'; +import type { BlockFieldRenderers } from '../types'; + type Props = { pageTitleField: FieldProp; descriptionField: FieldProp; + /** Draws the text fields, for an app whose inputs look nothing like these. */ + renderers?: BlockFieldRenderers; imageField?: FieldProp; renderImageField?: (field: FieldProp) => React.ReactNode; imagePreviewUrl?: string; @@ -50,6 +54,7 @@ function CharacterCount({ export default function SeoEditor({ pageTitleField, descriptionField, + renderers, imageField, renderImageField, imagePreviewUrl, @@ -78,25 +83,47 @@ export default function SeoEditor({ - Page Title - + {renderers?.text ? ( + renderers.text({ + name: 'pageTitle', + label: 'Page Title', + value: pageTitleField.value ?? null, + onChange: pageTitleField.handleValueChange, + }) + ) : ( + <> + Page Title + + + )} - Description - + {renderers?.textarea ? ( + renderers.textarea({ + name: 'description', + label: 'Description', + value: descriptionField.value ?? null, + onChange: descriptionField.handleValueChange, + }) + ) : ( + <> + Description + + + )} {imageField && renderImageField && renderImageField(imageField)} diff --git a/src/types.ts b/src/types.ts index 9901116..4a4bf56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,8 @@ export type BlockFieldRendererProps = { name: string; label: string; value: string | null; + /** The formats the field's schema allows, e.g. ['bold', 'link']. */ + features?: string[]; onChange: (value: string | null) => void; }; From f2cf5b21b8187f7e2bf0e3b4e3ea3eb1f2ad193c Mon Sep 17 00:00:00 2001 From: Tim Bartrum Date: Mon, 7 Sep 2026 14:39:09 +0100 Subject: [PATCH 4/7] fix(deps): React 19 types, matching the React the package builds against `react` and `react-dom` are already on 19 here while their types were pinned to 18. The mismatch is invisible to `yarn ts` but surfaces in a consumer that links this package during development: two copies of `@types/react` disagree, and the app's own components fail to check. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 4 ++-- yarn.lock | 23 +++++++++++------------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 00a7f12..44cf909 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,8 @@ "@eslint/js": "^9", "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", - "@types/react": "18", - "@types/react-dom": "18", + "@types/react": "19.1.5", + "@types/react-dom": "19.1.5", "date-fns": "^2.30.0", "eslint": "^9", "eslint-config-prettier": "^9", diff --git a/yarn.lock b/yarn.lock index 417d7fb..65b94d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -688,28 +688,27 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== -"@types/prop-types@*", "@types/prop-types@^15.7.14": +"@types/prop-types@^15.7.14": version "15.7.15" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== -"@types/react-dom@18": - version "18.3.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" - integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== +"@types/react-dom@19.1.5": + version "19.1.5" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.5.tgz#cdfe2c663742887372f54804b16e8dbc26bd794a" + integrity sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg== "@types/react-transition-group@^4.4.12": version "4.4.12" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@18": - version "18.3.31" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.31.tgz#b5e95e28ffcceab8d982f33f2eb076e17653c2a4" - integrity sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw== +"@types/react@19.1.5": + version "19.1.5" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.5.tgz#9feb3bdeb506d0c79d8533b6ebdcacdbcb4756db" + integrity sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g== dependencies: - "@types/prop-types" "*" - csstype "^3.2.2" + csstype "^3.0.2" "@typescript-eslint/eslint-plugin@8.65.0": version "8.65.0" @@ -992,7 +991,7 @@ cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" -csstype@^3.0.2, csstype@^3.1.3, csstype@^3.2.2: +csstype@^3.0.2, csstype@^3.1.3: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== From f626ccdf0396b5b6ff0e174f361ed147e71efa29 Mon Sep 17 00:00:00 2001 From: Tim Bartrum Date: Mon, 7 Sep 2026 15:39:20 +0100 Subject: [PATCH 5/7] feat: image, choice and note fields, and field hints The DCG designs ask more of a field than text: an image with the dimensions it should have, a radio group for a review's app store, a gallery of feature images, a currency prefix on an amount, and a card that names a part of the page an admin cannot edit at all. Uploading is the consuming app's, so `image` and `images` pass through to a renderer, `images` carrying the values it holds alongside the single value the other kinds use. `choice` and `note` need nothing an app owns, so they draw themselves. The preview renders the site at the device's real width and scrolls to what does not fit, rather than scaling the page down to the pane. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/BlocksField.tsx | 119 +++++++++++++++++++++++++++++++-- src/components/PageEditor.tsx | 34 ++-------- src/types.ts | 34 +++++++++- 3 files changed, 151 insertions(+), 36 deletions(-) diff --git a/src/components/BlocksField.tsx b/src/components/BlocksField.tsx index bf97c11..81c9a86 100644 --- a/src/components/BlocksField.tsx +++ b/src/components/BlocksField.tsx @@ -15,10 +15,11 @@ import { DialogTitle, Divider, IconButton, + InputAdornment, Stack, Typography, } from '@mui/material'; -import { FieldText } from '@m10c/mui-kit'; +import { FieldRadioGroup, FieldText } from '@m10c/mui-kit'; import React from 'react'; import { FieldProp } from 'react-typed-form'; @@ -232,7 +233,7 @@ type SimpleFieldRendererProps = { renderers?: BlockFieldRenderers; /** Overrides the rendered label (used to suffix a list item's index). */ labelOverride?: string; - onChange: (value: string | null) => void; + onChange: (value: unknown) => void; }; function SimpleFieldRenderer({ @@ -243,9 +244,14 @@ function SimpleFieldRenderer({ labelOverride, onChange, }: SimpleFieldRendererProps) { - const stringValue = typeof value === 'string' ? value : null; const label = labelOverride ?? fieldLabel(fieldDef, fieldKey); + if (fieldDef.kind === 'note') { + return ; + } + + const stringValue = typeof value === 'string' ? value : null; + const customRenderer: BlockFieldRenderer | undefined = renderers?.[fieldDef.kind]; if (customRenderer) { @@ -256,12 +262,39 @@ function SimpleFieldRenderer({ label, value: stringValue, features: fieldDef.features, + hint: fieldDef.hint, + prefix: fieldDef.prefix, + values: stringValues(value), + maxItems: fieldDef.maxItems, onChange, + onChangeValues: onChange, })} ); } + if (fieldDef.kind === 'choice') { + return ( + + + + ); + } + + // Uploading belongs to the consuming app, so an images field draws nothing + // of its own until a renderer is given for it. + if (fieldDef.kind === 'images') { + return ; + } + const fieldProp: FieldProp = { name: fieldKey, label, @@ -275,26 +308,90 @@ function SimpleFieldRenderer({ fieldDef.kind === 'richtext'; return ( - - {label} + + {fieldDef.prefix} + + ), + } + : undefined + } inputProps={ fieldDef.maxLength ? { maxLength: fieldDef.maxLength } : undefined } /> + + ); +} + +type FieldWrapProps = { + fieldDef: SimpleField; + label: string; + children?: React.ReactNode; +}; + +/** A field's label and hint, above whatever draws its value. */ +function FieldWrap({ fieldDef, label, children }: FieldWrapProps) { + return ( + + + {label} + {fieldDef.hint && ( + + {fieldDef.hint} + + )} + + {children} + + ); +} + +/** Names a part of the page an admin cannot edit, e.g. a contact form. */ +function FieldNote({ + fieldDef, + label, +}: { + fieldDef: SimpleField; + label: string; +}) { + return ( + + {fieldDef.label && {label}} + + {fieldDef.text} + ); } +/** The strings held by a field with several values, e.g. uploaded images. */ +function stringValues(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + /** A field's label, marked with an asterisk when it is required. */ function fieldLabel(fieldDef: BlockTypeField, fallback: string) { return `${fieldDef.label ?? fallback}${fieldDef.required ? '*' : ''}`; } +/** What a saved value reads as, naming the choice it stands for. */ +function optionLabel(fieldDef: SimpleField, value: string | null) { + const option = fieldDef.options?.find((item) => item.value === value); + return option?.label ?? value; +} + type ListItem = Record; type ListFieldRendererProps = { @@ -540,17 +637,25 @@ function ListItemCard({ {Object.entries(fieldDef.itemFields).map(([subKey, subFieldDef]) => { + const label = fieldLabel(subFieldDef, subKey); + if (subFieldDef.kind === 'note') { + return ( + + ); + } const value = typeof item[subKey] === 'string' ? item[subKey] : null; const preview = previews?.[subFieldDef.kind]; return ( - {fieldLabel(subFieldDef, subKey)} + {label} {preview ? ( preview(value) ) : ( - {value} + + {optionLabel(subFieldDef, value)} + )} ); diff --git a/src/components/PageEditor.tsx b/src/components/PageEditor.tsx index d6c560f..f623e65 100644 --- a/src/components/PageEditor.tsx +++ b/src/components/PageEditor.tsx @@ -203,36 +203,16 @@ function PreviewIframe({ src: string; renderWidth: number; }) { - const containerRef = React.useRef(null); - const [containerWidth, setContainerWidth] = React.useState(0); - - React.useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - setContainerWidth(entry.contentRect.width); - }); - - observer.observe(container); - return () => observer.disconnect(); - }, []); - if (!src) return null; - const scale = - containerWidth > 0 ? Math.min(1, containerWidth / renderWidth) : 0.5; - + // The site renders at its real width, so a device wider than the pane is + // scrolled to rather than scaled down. return ( - + @@ -241,12 +221,10 @@ function PreviewIframe({ src={src} title="Page preview" style={{ - width: renderWidth, - height: `${Math.round(100 / scale)}%`, + width: '100%', + height: '100%', border: 'none', backgroundColor: 'white', - transformOrigin: 'top left', - transform: `scale(${scale})`, display: 'block', borderRadius: 8, }} diff --git a/src/types.ts b/src/types.ts index 4a4bf56..3d2b008 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,17 @@ export type SimpleFieldKind = - 'text' | 'textarea' | 'richtext' | 'markdown' | 'image'; + | 'text' + | 'textarea' + | 'richtext' + | 'markdown' + | 'image' + | 'images' + | 'choice' + | 'note'; + +export type FieldOption = { + value: string; + label: string; +}; export type SimpleField = { kind: SimpleFieldKind; @@ -9,6 +21,16 @@ export type SimpleField = { maxLength?: number; /** Markdown feature flags, e.g. ['bold', 'italic', 'lists', 'links'] */ features?: string[]; + /** Guidance under the label, e.g. the dimensions an image should have. */ + hint?: string; + /** Fixed text before the input, e.g. a currency symbol. */ + prefix?: string; + /** What a `note` field says in place of an input. */ + text?: string; + /** What a `choice` field offers. */ + options?: FieldOption[]; + /** Caps how many an `images` field holds. */ + maxItems?: number; }; export type ListField = { @@ -73,7 +95,17 @@ export type BlockFieldRendererProps = { value: string | null; /** The formats the field's schema allows, e.g. ['bold', 'link']. */ features?: string[]; + /** Guidance under the label, e.g. the dimensions an image should have. */ + hint?: string; + /** Fixed text before the input, e.g. a currency symbol. */ + prefix?: string; + /** The saved values of a field that holds several, e.g. `images`. */ + values?: string[]; + /** Caps how many values a field that holds several accepts. */ + maxItems?: number; onChange: (value: string | null) => void; + /** Replaces the values of a field that holds several. */ + onChangeValues?: (values: string[]) => void; }; export type BlockFieldRenderer = ( From 4d08077cbd5951e2326b4a583aa792b91531c7ea Mon Sep 17 00:00:00 2001 From: Tim Bartrum Date: Mon, 7 Sep 2026 16:20:01 +0100 Subject: [PATCH 6/7] feat: fall back to the site's own share image in the preview A page that names no image of its own is still shared with one: the site's default. The preview drew an empty grey block instead, so it disagreed with what a post would look like. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/SeoEditor.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/SeoEditor.tsx b/src/components/SeoEditor.tsx index 98fe4d5..7195d82 100644 --- a/src/components/SeoEditor.tsx +++ b/src/components/SeoEditor.tsx @@ -15,6 +15,8 @@ type Props = { imageField?: FieldProp; renderImageField?: (field: FieldProp) => React.ReactNode; imagePreviewUrl?: string; + /** The image the site shares when a page names none of its own. */ + fallbackImageUrl?: string; /** The site's favicon, shown next to the URL in the search preview (search * engines use the favicon here, not the social/OG image). */ faviconUrl?: string; @@ -58,6 +60,7 @@ export default function SeoEditor({ imageField, renderImageField, imagePreviewUrl, + fallbackImageUrl, faviconUrl, fallbackTitle, siteName = '', @@ -66,6 +69,7 @@ export default function SeoEditor({ }: Props) { const pageTitle = pageTitleField.value ?? ''; const description = descriptionField.value ?? ''; + const socialImageUrl = imagePreviewUrl ?? fallbackImageUrl; return ( Date: Tue, 8 Sep 2026 11:58:23 +0100 Subject: [PATCH 7/7] fix: round the search preview's favicon as search engines do The chip was a rounded square the favicon filled edge to edge, so the icon's own corners sat outside it. Search engines draw the favicon inset inside a circle, so the chip is one too. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/SeoEditor.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/SeoEditor.tsx b/src/components/SeoEditor.tsx index 7195d82..ca44b1f 100644 --- a/src/components/SeoEditor.tsx +++ b/src/components/SeoEditor.tsx @@ -26,6 +26,12 @@ type Props = { onPublish: () => void; }; +/** Diameter of the circle search engines draw the favicon inside. */ +const FAVICON_CHIP_SIZE = 26; + +/** The favicon itself, inset so it stays within the circle. */ +const FAVICON_SIZE = 20; + function CharacterCount({ value, min, @@ -149,15 +155,15 @@ export default function SeoEditor({