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(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 = JSON.parse(JSON.stringify(data.props ?? {})); delete props.node_id; const children: string[] = [...(data.nodes || [])]; const linked: Record = 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; } }