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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user