site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith

Three related features:

1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction.
   New shared ctas[] array (text + href + variant + target) replaces the
   primary/secondary pair. Settings panel gets add/remove/reorder controls.
   Legacy fields stay readable for backwards compat — first user edit
   migrates the section onto the new array.

2. Anchor IDs on all layout/section components (Container, Section,
   BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel
   subagent, plus Hero/CTA/CallToAction). Anchor input lives in the
   settings panel with an "auto from heading" button that walks the
   subtree for the first Heading.text. Renders as id="..." on the
   outermost element so #anchor URLs resolve.

3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith"
   and a button at the top of the right-side settings panel both open the
   modal pre-targeted at the selected node. The node's serialized subtree
   is sent to the server; system prompt is augmented to require a patch
   with replace_node. Editor lifts modal state into a new SitesmithContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:43:28 -07:00
parent 7b747f775f
commit d0925d9e2d
24 changed files with 723 additions and 237 deletions
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import { useNode, useEditor } from '@craftjs/core';
/**
* Reusable anchor-id input for any section/layout component. Lets the user
* set a stable URL fragment (e.g. #about) and auto-fills from the first
* heading found inside the node's subtree.
*
* Uses the editor query (more reliable than DOM lookup) to walk the Craft.js
* node tree and find the first Heading component's `text` prop.
*/
export const AnchorIdField: React.FC = () => {
const { id, actions: { setProp }, props, nodeName } = useNode((node) => ({
props: node.data.props as { anchorId?: string },
nodeName: node.data.displayName,
}));
const { query } = useEditor();
const value = (props.anchorId ?? '').toString();
const slugify = (s: string) =>
s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').slice(0, 60);
// Walk the subtree via editor query looking for the first Heading's `text` prop.
const findFirstHeadingText = (): string | null => {
const walk = (nodeId: string): string | null => {
try {
const n = query.node(nodeId).get();
if (n.data.displayName === 'Heading') {
return ((n.data.props as any).text as string | undefined) ?? null;
}
for (const childId of n.data.nodes ?? []) {
const r = walk(childId);
if (r) return r;
}
for (const childId of Object.values(n.data.linkedNodes ?? {})) {
const r = walk(childId as string);
if (r) return r;
}
} catch {
return null;
}
return null;
};
try { return walk(id); } catch { return null; }
};
const autoFill = () => {
const txt = findFirstHeadingText();
if (txt) setProp((p: any) => { p.anchorId = slugify(txt); });
};
const labelStyle: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4, fontWeight: 500 };
return (
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: '1px solid var(--color-border)' }}>
<label style={labelStyle}>Anchor ID (URL fragment)</label>
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ color: 'var(--color-text-dim)', fontSize: 13, padding: '6px 4px 6px 8px', background: 'var(--color-bg-base)', borderTopLeftRadius: 'var(--radius-sm)', borderBottomLeftRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)', borderRight: 'none', fontFamily: 'monospace' }}>#</span>
<input
type="text"
value={value}
onChange={(e) => setProp((p: any) => { p.anchorId = slugify(e.target.value); })}
placeholder="optional"
className="control-input"
style={{ flex: 1, fontSize: 12, fontFamily: 'monospace', borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
/>
<button
type="button"
onClick={autoFill}
title="Auto-fill from first heading inside this block"
style={{ padding: '4px 8px', fontSize: 11, background: 'var(--color-bg-base)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer', whiteSpace: 'nowrap' }}
>
From heading
</button>
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-dim)', marginTop: 4 }}>
Link to this {nodeName?.toLowerCase() ?? 'block'} from anywhere with <code style={{ fontFamily: 'monospace' }}>#{value || 'your-anchor'}</code>
</div>
</div>
);
};