Refs #52 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
177 lines
7.1 KiB
TypeScript
177 lines
7.1 KiB
TypeScript
import { useMemo } from 'react';
|
|
import type { SerializedTreeNode } from '../types/sitesmith';
|
|
import type { TemplateComponent } from '../templates/definitions';
|
|
import { templateComponentToTreeNode } from '../templates/apply-template';
|
|
import { buildNodeTree } from '../utils/craft-tree';
|
|
import { getAncestorIds, getIndexInParent } from '../utils/craft-helpers';
|
|
import { componentResolver } from '../components/resolver';
|
|
import { useProjectExtras } from '../state/ProjectExtrasContext';
|
|
import { BUILTIN_SECTIONS } from './families';
|
|
|
|
export type SectionFamily =
|
|
| 'hero' | 'services' | 'about' | 'social-proof' | 'faq' | 'contact'
|
|
| 'hours' | 'cta' | 'pricing' | 'gallery' | 'team' | 'custom';
|
|
|
|
export interface SectionDef {
|
|
id: string;
|
|
name: string;
|
|
family: SectionFamily;
|
|
keywords: string[];
|
|
/** Token-based template tree (same DSL as templates/definitions.ts). */
|
|
tree: TemplateComponent;
|
|
}
|
|
|
|
export const SECTION_FAMILIES: Array<{ id: SectionFamily; label: string }> = [
|
|
{ id: 'hero', label: 'Hero' },
|
|
{ id: 'services', label: 'Services' },
|
|
{ id: 'about', label: 'About' },
|
|
{ id: 'social-proof', label: 'Social proof' },
|
|
{ id: 'faq', label: 'FAQ' },
|
|
{ id: 'contact', label: 'Contact' },
|
|
{ id: 'hours', label: 'Hours' },
|
|
{ id: 'cta', label: 'Call to action' },
|
|
{ id: 'pricing', label: 'Pricing' },
|
|
{ id: 'gallery', label: 'Gallery' },
|
|
{ id: 'team', label: 'Team' },
|
|
{ id: 'custom', label: 'My sections' },
|
|
];
|
|
|
|
/** `extras.customSections` entry: a section the user saved from their canvas. */
|
|
export interface CustomSection {
|
|
id: string;
|
|
name: string;
|
|
family: 'custom';
|
|
tree: SerializedTreeNode;
|
|
createdAt: number;
|
|
}
|
|
|
|
export const CUSTOM_SECTIONS_KEY = 'customSections';
|
|
|
|
/** Serialized tree -> the TemplateComponent DSL (inverse of templateComponentToTreeNode). */
|
|
function serializedToTemplate(node: SerializedTreeNode): TemplateComponent {
|
|
const comp: TemplateComponent = { type: node.type.resolvedName, props: { ...node.props } };
|
|
if (node.nodes && node.nodes.length) comp.children = node.nodes.map(serializedToTemplate);
|
|
return comp;
|
|
}
|
|
|
|
export function customToDef(c: CustomSection): SectionDef {
|
|
return { id: c.id, name: c.name, family: 'custom', keywords: [], tree: serializedToTemplate(c.tree) };
|
|
}
|
|
|
|
export function getSections(custom: CustomSection[] = []): SectionDef[] {
|
|
return [...BUILTIN_SECTIONS, ...custom.map(customToDef)];
|
|
}
|
|
|
|
/** Built-ins plus the site's own saved sections. Reads extras at render time
|
|
* (extras are ref-backed; callers that mutate them re-render themselves). */
|
|
export function useSections(version = 0, open = true): SectionDef[] {
|
|
const extras = useProjectExtras();
|
|
return useMemo(
|
|
() => getSections(extras.get<CustomSection[]>(CUSTOM_SECTIONS_KEY, [])),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// `open` re-reads after a silent extras.replaceAll (project load) that never re-rendered us.
|
|
[extras, version, open],
|
|
);
|
|
}
|
|
|
|
/** Case-insensitive match over name, family label and keywords; every query
|
|
* word must hit. Optional family restricts the pool first. */
|
|
export function searchSections(q: string, family?: SectionFamily | null, pool: SectionDef[] = getSections()): SectionDef[] {
|
|
const inFamily = family ? pool.filter((s) => s.family === family) : pool;
|
|
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (words.length === 0) return inFamily;
|
|
return inFamily.filter((s) => {
|
|
const label = SECTION_FAMILIES.find((f) => f.id === s.family)?.label ?? '';
|
|
const hay = [s.name, label, s.family, ...s.keywords].join(' ').toLowerCase();
|
|
return words.every((w) => hay.includes(w));
|
|
});
|
|
}
|
|
|
|
export interface InsertSectionOptions {
|
|
/** Insert after the top-level section containing the selection (default true). */
|
|
afterSelection?: boolean;
|
|
/** Select the new root (default true). */
|
|
select?: boolean;
|
|
}
|
|
|
|
/** The id of the ROOT child that contains `id` (or is `id`), else null. */
|
|
function topLevelAncestor(query: any, id: string): string | null {
|
|
if (!id || id === 'ROOT') return null;
|
|
const chain = [id, ...getAncestorIds(query, id)];
|
|
return chain[chain.length - 1];
|
|
}
|
|
|
|
/** Materialize a section into ROOT: after the selected top-level section, or
|
|
* appended. Fresh node ids come from buildNodeTree. Returns the new root id. */
|
|
export function insertSection(query: any, actions: any, def: SectionDef, opts: InsertSectionOptions = {}): string {
|
|
const tree = buildNodeTree(query, templateComponentToTreeNode(def.tree));
|
|
let index: number | undefined;
|
|
if (opts.afterSelection !== false) {
|
|
try {
|
|
const sel: string[] = query.getEvent('selected').all();
|
|
const top = sel.length ? topLevelAncestor(query, sel[0]) : null;
|
|
if (top) {
|
|
const idx = getIndexInParent(query, top);
|
|
if (idx !== -1) index = idx + 1;
|
|
}
|
|
} catch {
|
|
// No usable selection: append.
|
|
}
|
|
}
|
|
actions.addNodeTree(tree, 'ROOT', index);
|
|
if (opts.select !== false) {
|
|
try { actions.selectNode(tree.rootNodeId); } catch { /* nothing to select */ }
|
|
}
|
|
return tree.rootNodeId;
|
|
}
|
|
|
|
function resolvedNameOf(data: any): string | null {
|
|
if (typeof data?.name === 'string' && data.name) return data.name;
|
|
if (data?.type?.resolvedName) return data.type.resolvedName;
|
|
for (const [name, comp] of Object.entries(componentResolver)) {
|
|
if (comp === data?.type) return name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Live Craft subtree -> SerializedTreeNode (the shape buildNodeTree takes).
|
|
* Shell wrappers (Section's inner Container) are flattened back to plain
|
|
* children and ColumnLayout columns back to child nodes, the inverse of
|
|
* flattenTreeForCraft. `node_id` is dropped so a re-insert gets fresh ids;
|
|
* `aiName` is kept.
|
|
*/
|
|
export function nodeTreeToSerializedTree(query: any, rootId: string): SerializedTreeNode {
|
|
const walk = (id: string): SerializedTreeNode => {
|
|
const data = query.node(id).get()?.data;
|
|
const name = resolvedNameOf(data);
|
|
if (!name) throw new Error(`nodeTreeToSerializedTree: unknown component for node ${id}`);
|
|
const props: Record<string, unknown> = JSON.parse(JSON.stringify(data.props ?? {}));
|
|
delete props.node_id;
|
|
const children: string[] = [...(data.nodes || [])];
|
|
const linked: Record<string, string> = data.linkedNodes || {};
|
|
const out: SerializedTreeNode = { type: { resolvedName: name }, props };
|
|
const kids: SerializedTreeNode[] = children.map(walk);
|
|
if (name === 'ColumnLayout') {
|
|
Object.keys(linked)
|
|
.sort((a, b) => Number(a.replace(/\D/g, '')) - Number(b.replace(/\D/g, '')))
|
|
.forEach((k) => kids.push(walk(linked[k])));
|
|
} else {
|
|
for (const innerId of Object.values(linked)) {
|
|
// Section / BackgroundSection / FormContainer inner canvas: hoist its children.
|
|
const inner = query.node(innerId).get()?.data;
|
|
for (const cid of inner?.nodes || []) kids.push(walk(cid));
|
|
}
|
|
}
|
|
if (kids.length) out.nodes = kids;
|
|
return out;
|
|
};
|
|
return walk(rootId);
|
|
}
|
|
|
|
/** True when `id` is a direct child of ROOT (a saveable top-level section). */
|
|
export function isTopLevelSection(query: any, id: string | undefined): boolean {
|
|
if (!id || id === 'ROOT') return false;
|
|
try { return query.node(id).get()?.data?.parent === 'ROOT'; } catch { return false; }
|
|
}
|