feat(site-builder): section library catalog, modal and insertion
Catalog skeleton with per-family files (one seed each for hero, services, contact), live token-styled thumbnails, insert-after-selection, and save-as-section into project extras. Refs #52 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8860349c9b
commit
6e819f1ef2
23 files changed
+903
-10
No files matched your search
@@ -9,6 +9,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../state/MobileChromeContext';
|
||||
import { useSitesmithModal } from '../state/SitesmithContext';
|
||||
import { insertBlock } from '../panels/left/blocks-registry';
|
||||
import { openSectionLibrary } from '../panels/sections/SectionLibraryModal';
|
||||
|
||||
interface CanvasProps {
|
||||
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
|
||||
@@ -109,8 +110,8 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
|
||||
* opt back in.
|
||||
*/
|
||||
|
||||
/** "Add a section" action. Kept as one named function so #52 can point it at
|
||||
* the section library with a one-line swap. */
|
||||
/** Blank-section insert (no longer wired to the empty-state button, which
|
||||
* opens the section library). */
|
||||
export function addSectionToRoot(query: any, actions: any): string | null {
|
||||
return insertBlock(query, actions, 'section', { parentId: 'ROOT', select: true });
|
||||
}
|
||||
@@ -141,7 +142,7 @@ export const EmptyCanvasHint: React.FC = () => {
|
||||
<button type="button" className="empty-canvas-btn" onClick={() => setTemplateModalOpen(true)}>
|
||||
<i className="fa fa-th-large" aria-hidden /> Choose a template
|
||||
</button>
|
||||
<button type="button" className="empty-canvas-btn" onClick={() => addSectionToRoot(query, actions)}>
|
||||
<button type="button" className="empty-canvas-btn" onClick={() => openSectionLibrary()}>
|
||||
<i className="fa fa-plus" aria-hidden /> Add a section
|
||||
</button>
|
||||
<button type="button" className="empty-canvas-btn" onClick={() => openSitesmith()}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NoticeHost } from './NoticeHost';
|
||||
import { EmptyContainerAdd } from './EmptyContainerAdd';
|
||||
import { DropIndicator } from './DropIndicator';
|
||||
import { RichTextToolbar } from './RichTextToolbar';
|
||||
import { SectionLibraryModal } from '../panels/sections/SectionLibraryModal';
|
||||
|
||||
/**
|
||||
* Single mount point for editor-wide overlays rendered next to the shell.
|
||||
@@ -15,5 +16,6 @@ export const CanvasOverlays: React.FC = () => (
|
||||
<EmptyContainerAdd />
|
||||
<DropIndicator />
|
||||
<RichTextToolbar />
|
||||
<SectionLibraryModal />
|
||||
</>
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { EmptyCanvasHint, addSectionToRoot } from './Canvas';
|
||||
import { SitesmithProvider, useSitesmithModal } from '../state/SitesmithContext';
|
||||
import { closeSectionLibrary, useSectionLibraryState } from '../panels/sections/SectionLibraryModal';
|
||||
import { MobileChromeProvider, useMobileChrome } from '../state/MobileChromeContext';
|
||||
|
||||
const forceDragging = vi.hoisted(() => ({ on: false }));
|
||||
@@ -21,9 +22,11 @@ vi.mock('@craftjs/core', async (orig) => {
|
||||
|
||||
/* Real @craftjs/core editor (no mocks); providers wrap the whole <Editor>. */
|
||||
let h: EditorHarness;
|
||||
let libState = { open: false, saving: false };
|
||||
let seen: { sitesmithOpen: boolean; templateOpen: boolean };
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
libState = useSectionLibraryState();
|
||||
seen = { sitesmithOpen: useSitesmithModal().isOpen, templateOpen: useMobileChrome().templateModalOpen };
|
||||
return null;
|
||||
};
|
||||
@@ -54,14 +57,13 @@ describe('EmptyCanvasHint', () => {
|
||||
expect(btn('Generate with Sitesmith')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('"Add a section" adds a Section under ROOT, selects it, and hides the hint', () => {
|
||||
test('"Add a section" opens the section library and leaves the canvas empty', () => {
|
||||
mount();
|
||||
expect(libState.open).toBe(false);
|
||||
h.act(() => { btn('Add a section').click(); });
|
||||
const rootNodes: string[] = h.query.node('ROOT').get().data.nodes;
|
||||
expect(rootNodes).toHaveLength(1);
|
||||
expect(h.query.node(rootNodes[0]).get().data.displayName).toBe('Section');
|
||||
expect(h.query.getEvent('selected').all()).toEqual([rootNodes[0]]);
|
||||
expect(h.container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
expect(libState.open).toBe(true);
|
||||
expect(h.query.node('ROOT').get().data.nodes).toHaveLength(0);
|
||||
closeSectionLibrary();
|
||||
});
|
||||
|
||||
test('Sitesmith button opens the Sitesmith modal', () => {
|
||||
@@ -80,7 +82,7 @@ describe('EmptyCanvasHint', () => {
|
||||
|
||||
test('hides once the page has content', () => {
|
||||
mount();
|
||||
h.act(() => { btn('Add a section').click(); });
|
||||
h.act(() => { addSectionToRoot(h.query, h.actions); });
|
||||
expect(nodeTypes()).toContain('Section');
|
||||
expect(h.container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../../state/MobileChromeContext';
|
||||
import { getPref, setPref } from '../../utils/local-prefs';
|
||||
import { openSectionLibrary } from '../sections/SectionLibraryModal';
|
||||
import { BLOCKS, BLOCK_CATEGORIES, BlockDef, insertBlock, searchBlocks } from './blocks-registry';
|
||||
|
||||
// The business category is empty until its blocks land; hide empty groups.
|
||||
@@ -109,6 +110,9 @@ export const BlocksPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className="empty-canvas-btn block-browse-sections" onClick={() => openSectionLibrary()}>
|
||||
<i className="fa fa-columns" aria-hidden /> Browse sections
|
||||
</button>
|
||||
<div className="block-search">
|
||||
<input
|
||||
type="search"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext';
|
||||
import { SaveGuardProvider } from '../../state/SaveGuardContext';
|
||||
import { SiteDesignProvider } from '../../state/SiteDesignContext';
|
||||
import { __resetCommandsForTests, getCommand } from '../../editor/commands';
|
||||
import { SectionLibraryModal, openSectionLibrary, closeSectionLibrary } from './SectionLibraryModal';
|
||||
import { BlocksPanel } from '../left/BlocksPanel';
|
||||
|
||||
let h: EditorHarness;
|
||||
let extrasApi: ReturnType<typeof useProjectExtras>;
|
||||
const Grab: React.FC = () => { extrasApi = useProjectExtras(); return null; };
|
||||
|
||||
function mount() {
|
||||
h = renderEditorHarness({
|
||||
wrap: (tree) => (
|
||||
<SaveGuardProvider><ProjectExtrasProvider><SiteDesignProvider>{tree}<Grab /></SiteDesignProvider></ProjectExtrasProvider></SaveGuardProvider>
|
||||
),
|
||||
});
|
||||
h.mountChild(<><SectionLibraryModal /><BlocksPanel /></>);
|
||||
}
|
||||
const $ = (sel: string) => h.container.querySelector(sel) as HTMLElement | null;
|
||||
const $$ = (sel: string) => [...h.container.querySelectorAll(sel)] as HTMLElement[];
|
||||
const rootKids = () => h.query.node('ROOT').get().data.nodes as string[];
|
||||
const type = (el: HTMLInputElement, v: string) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
|
||||
h.act(() => { setter.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||
};
|
||||
|
||||
beforeEach(() => __resetCommandsForTests());
|
||||
afterEach(() => { h?.unmount(); closeSectionLibrary(); });
|
||||
|
||||
describe('SectionLibraryModal', () => {
|
||||
test('closed by default; Browse sections button in the Blocks panel opens it', () => {
|
||||
mount();
|
||||
expect($('.section-lib')).toBeNull();
|
||||
h.act(() => { ($$('button').find((b) => b.textContent?.includes('Browse sections')))!.click(); });
|
||||
expect($('.section-lib')).not.toBeNull();
|
||||
expect($$('.section-lib-card').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('thumbnails are sandboxed, script-less iframes in the site palette', () => {
|
||||
mount();
|
||||
h.act(() => openSectionLibrary());
|
||||
const frame = $('iframe.section-lib-frame') as HTMLIFrameElement;
|
||||
expect(frame.getAttribute('sandbox')).toBe('');
|
||||
expect(frame.getAttribute('srcdoc')).toContain('--wsb-primary:');
|
||||
});
|
||||
|
||||
test('family chip filters and search narrows', () => {
|
||||
mount();
|
||||
h.act(() => openSectionLibrary());
|
||||
const chip = (l: string) => $$('.section-lib-chip').find((c) => c.textContent === l)!;
|
||||
h.act(() => chip('Hero').click());
|
||||
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual(['hero-centered']);
|
||||
h.act(() => chip('All').click());
|
||||
type($('.section-lib-search') as HTMLInputElement, 'form');
|
||||
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toContain('contact-form');
|
||||
type($('.section-lib-search') as HTMLInputElement, 'zzzz');
|
||||
expect($$('.section-lib-card')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('clicking a card inserts the section, selects it and closes', () => {
|
||||
mount();
|
||||
h.act(() => openSectionLibrary());
|
||||
h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
|
||||
expect(rootKids()).toHaveLength(1);
|
||||
expect(h.query.getEvent('selected').all()).toEqual([rootKids()[0]]);
|
||||
expect($('.section-lib')).toBeNull();
|
||||
});
|
||||
|
||||
test('registers insert.section-library and site.save-as-section (only for a top-level section)', () => {
|
||||
mount();
|
||||
expect(getCommand('insert.section-library')).toBeTruthy();
|
||||
const save = getCommand('site.save-as-section')!;
|
||||
const ctx = (ids: string[]) => ({ query: h.query, selectedIds: ids } as any);
|
||||
expect(save.when!(ctx([]))).toBe(false);
|
||||
h.act(() => openSectionLibrary());
|
||||
h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
|
||||
const id = rootKids()[0];
|
||||
expect(save.when!(ctx([id]))).toBe(true);
|
||||
const inner = h.query.node(id).decendants(true)[0];
|
||||
expect(save.when!(ctx([inner]))).toBe(false);
|
||||
});
|
||||
|
||||
test('save current section -> extras.customSections, shown under My sections, deletable', () => {
|
||||
mount();
|
||||
h.act(() => openSectionLibrary());
|
||||
h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
|
||||
h.act(() => openSectionLibrary());
|
||||
h.act(() => { $$('button').find((b) => b.textContent?.includes('Save current section'))!.click(); });
|
||||
type($('input[aria-label="Section name"]') as HTMLInputElement, 'My hero');
|
||||
h.act(() => { $$('button').find((b) => b.textContent === 'Save')!.click(); });
|
||||
const saved = extrasApi.get<any[]>('customSections', []);
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]).toMatchObject({ name: 'My hero', family: 'custom' });
|
||||
expect(saved[0].tree.type.resolvedName).toBe('Section');
|
||||
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual([saved[0].id]);
|
||||
h.act(() => { ($('.section-lib-delete') as HTMLElement).click(); });
|
||||
expect(extrasApi.get<any[]>('customSections', [])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { useProjectExtras } from '../../state/ProjectExtrasContext';
|
||||
import { useNotices } from '../../state/NoticeContext';
|
||||
import { useRegisterCommands } from '../../editor/commands';
|
||||
import {
|
||||
CUSTOM_SECTIONS_KEY, CustomSection, SECTION_FAMILIES, SectionDef, SectionFamily,
|
||||
insertSection, isTopLevelSection, nodeTreeToSerializedTree, searchSections, useSections,
|
||||
} from '../../sections/catalog';
|
||||
import { buildSectionPreviewDoc } from '../../sections/thumbnails';
|
||||
|
||||
// ---- open state: module-level so the command, the empty-state button and the
|
||||
// Blocks panel button can open the modal without a provider ----
|
||||
interface LibraryState { open: boolean; saving: boolean }
|
||||
let state: LibraryState = { open: false, saving: false };
|
||||
const listeners = new Set<() => void>();
|
||||
const setState = (s: LibraryState) => { state = s; listeners.forEach((l) => l()); };
|
||||
const subscribe = (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; };
|
||||
|
||||
export function openSectionLibrary(opts: { saving?: boolean } = {}): void {
|
||||
setState({ open: true, saving: !!opts.saving });
|
||||
}
|
||||
export function closeSectionLibrary(): void { setState({ open: false, saving: false }); }
|
||||
export function useSectionLibraryState(): LibraryState {
|
||||
return useSyncExternalStore(subscribe, () => state, () => state);
|
||||
}
|
||||
|
||||
/** Renders its iframe only once scrolled into view (60 live iframes at once is heavy). */
|
||||
const LazyPreview: React.FC<{ def: SectionDef; title: string }> = ({ def, title }) => {
|
||||
const { design } = useSiteDesign();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [seen, setSeen] = useState(typeof IntersectionObserver === 'undefined');
|
||||
useEffect(() => {
|
||||
if (seen || !ref.current) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) { setSeen(true); io.disconnect(); }
|
||||
});
|
||||
io.observe(ref.current);
|
||||
return () => io.disconnect();
|
||||
}, [seen]);
|
||||
const doc = useMemo(() => {
|
||||
if (!seen) return '';
|
||||
try { return buildSectionPreviewDoc(def, design); } catch { return ''; }
|
||||
}, [seen, def, design]);
|
||||
return (
|
||||
<div className="section-lib-thumb" ref={ref}>
|
||||
{doc && (
|
||||
<iframe
|
||||
className="section-lib-frame"
|
||||
title={title}
|
||||
sandbox=""
|
||||
srcDoc={doc}
|
||||
tabIndex={-1}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SectionLibraryModal: React.FC = () => {
|
||||
const { open, saving } = useSectionLibraryState();
|
||||
const { query, actions, selectedId } = useEditor((s) => ({ selectedId: Array.from(s.events.selected || [])[0] as string | undefined }));
|
||||
const extras = useProjectExtras();
|
||||
const { notify } = useNotices();
|
||||
const [version, setVersion] = useState(0);
|
||||
const sections = useSections(version);
|
||||
const [family, setFamily] = useState<SectionFamily | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [saveName, setSaveName] = useState('');
|
||||
|
||||
const canSave = open && isTopLevelSection(query, selectedId);
|
||||
|
||||
useRegisterCommands([
|
||||
{
|
||||
id: 'insert.section-library',
|
||||
title: 'Browse sections…',
|
||||
group: 'insert',
|
||||
keywords: ['section', 'library', 'hero', 'faq', 'template', 'add section'],
|
||||
run: () => openSectionLibrary(),
|
||||
},
|
||||
{
|
||||
id: 'site.save-as-section',
|
||||
title: 'Save selection as section…',
|
||||
group: 'site',
|
||||
keywords: ['section', 'library', 'save', 'reusable'],
|
||||
when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]),
|
||||
run: () => openSectionLibrary({ saving: true }),
|
||||
},
|
||||
], []);
|
||||
|
||||
useEffect(() => { if (open) { setQ(''); setFamily(null); setSaveName(''); } }, [open]);
|
||||
|
||||
const results = useMemo(() => searchSections(q, family, sections), [q, family, sections]);
|
||||
|
||||
const onPick = useCallback((def: SectionDef) => {
|
||||
try {
|
||||
const id = insertSection(query, actions, def, { afterSelection: true, select: true });
|
||||
closeSectionLibrary();
|
||||
notify({ kind: 'success', message: `Added "${def.name}"` });
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
try { query.node(id).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* best effort */ }
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to insert section:', e);
|
||||
notify({ kind: 'error', message: 'Could not add that section.' });
|
||||
}
|
||||
}, [query, actions, notify]);
|
||||
|
||||
const onSave = () => {
|
||||
const name = saveName.trim();
|
||||
if (!name || !selectedId) return;
|
||||
try {
|
||||
const entry: CustomSection = {
|
||||
id: `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
name,
|
||||
family: 'custom',
|
||||
tree: nodeTreeToSerializedTree(query, selectedId),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
extras.update<CustomSection[]>(CUSTOM_SECTIONS_KEY, (prev) => [...prev, entry], []);
|
||||
setVersion((v) => v + 1);
|
||||
setSaveName('');
|
||||
setFamily('custom');
|
||||
openSectionLibrary({ saving: false });
|
||||
notify({ kind: 'success', message: `Saved "${name}" to My sections` });
|
||||
} catch (e) {
|
||||
console.error('Failed to save section:', e);
|
||||
notify({ kind: 'error', message: 'Could not save that section.' });
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
extras.update<CustomSection[]>(CUSTOM_SECTIONS_KEY, (prev) => prev.filter((c) => c.id !== id), []);
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={closeSectionLibrary}
|
||||
width="min(960px, 94vw)"
|
||||
backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Section library' }}
|
||||
>
|
||||
<div className="section-lib">
|
||||
<div className="section-lib-head">
|
||||
<h2>Add a section</h2>
|
||||
<button type="button" className="section-lib-close" aria-label="Close" onClick={closeSectionLibrary}>
|
||||
<i className="fa fa-times" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="section-lib-controls">
|
||||
<input
|
||||
type="search"
|
||||
className="section-lib-search"
|
||||
placeholder="Search sections…"
|
||||
aria-label="Search sections"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
{canSave && !saving && (
|
||||
<button type="button" className="section-lib-save-btn" onClick={() => openSectionLibrary({ saving: true })}>
|
||||
<i className="fa fa-bookmark" aria-hidden /> Save current section
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{saving && canSave && (
|
||||
<form className="section-lib-save" onSubmit={(e) => { e.preventDefault(); onSave(); }}>
|
||||
<input
|
||||
className="section-lib-search"
|
||||
placeholder="Name this section"
|
||||
aria-label="Section name"
|
||||
value={saveName}
|
||||
autoFocus
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="section-lib-save-btn" disabled={!saveName.trim()}>Save</button>
|
||||
<button type="button" className="section-lib-save-btn" onClick={() => openSectionLibrary({ saving: false })}>Cancel</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="section-lib-chips" role="tablist" aria-label="Section families">
|
||||
<button type="button" role="tab" aria-selected={family === null} className={`section-lib-chip${family === null ? ' active' : ''}`} onClick={() => setFamily(null)}>All</button>
|
||||
{SECTION_FAMILIES.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={family === f.id}
|
||||
className={`section-lib-chip${family === f.id ? ' active' : ''}`}
|
||||
onClick={() => setFamily(f.id)}
|
||||
>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-lib-grid">
|
||||
{results.map((def) => (
|
||||
<div key={def.id} className="section-lib-card" data-section-id={def.id}>
|
||||
<button type="button" className="section-lib-pick" onClick={() => onPick(def)} aria-label={`Add ${def.name}`}>
|
||||
<LazyPreview def={def} title={`${def.name} preview`} />
|
||||
<span className="section-lib-name">{def.name}</span>
|
||||
</button>
|
||||
{def.family === 'custom' && (
|
||||
<button type="button" className="section-lib-delete" aria-label={`Delete ${def.name}`} onClick={() => onDelete(def.id)}>
|
||||
<i className="fa fa-trash" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<div className="section-lib-empty">
|
||||
{family === 'custom' ? 'Nothing saved yet. Select a section on the canvas and choose "Save current section".' : `No sections match "${q.trim()}"`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { flattenTreeForCraft } from '../utils/craft-tree';
|
||||
import { templateComponentToTreeNode } from '../templates/apply-template';
|
||||
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
import {
|
||||
SECTION_FAMILIES, getSections, insertSection, nodeTreeToSerializedTree, searchSections, type SectionDef, type CustomSection,
|
||||
} from './catalog';
|
||||
import { buildSectionPreviewDoc } from './thumbnails';
|
||||
import { HERO_SECTIONS } from './families';
|
||||
|
||||
let h: EditorHarness;
|
||||
afterEach(() => h?.unmount());
|
||||
|
||||
const hero = HERO_SECTIONS[0];
|
||||
const rootKids = () => h.query.node('ROOT').get().data.nodes as string[];
|
||||
const nameOf = (id: string) => h.query.node(id).get().data.displayName;
|
||||
|
||||
function addBlank(): string {
|
||||
const sec: SectionDef = { id: 'x', name: 'x', family: 'hero', keywords: [], tree: { type: 'Section', props: {}, children: [{ type: 'Heading', props: { text: 'blank' } }] } };
|
||||
return insertSection(h.query, h.actions, sec, { select: false });
|
||||
}
|
||||
|
||||
describe('insertSection (real editor)', () => {
|
||||
test('inserts after the selected top-level section and selects the new root', () => {
|
||||
h = renderEditorHarness();
|
||||
const a = addBlank();
|
||||
const b = addBlank();
|
||||
expect(rootKids()).toEqual([a, b]);
|
||||
h.act(() => { h.actions.selectNode(a); });
|
||||
let id = '';
|
||||
h.act(() => { id = insertSection(h.query, h.actions, hero); });
|
||||
expect(rootKids()).toEqual([a, id, b]);
|
||||
expect(h.query.getEvent('selected').all()).toEqual([id]);
|
||||
});
|
||||
|
||||
test('a selection nested inside a section resolves to its top-level ancestor', () => {
|
||||
h = renderEditorHarness();
|
||||
const a = addBlank();
|
||||
const b = addBlank();
|
||||
const nested = Object.keys(h.query.getNodes()).find((k) => nameOf(k) === 'Heading' && h.query.node(k).ancestors().includes(a))!;
|
||||
h.act(() => { h.actions.selectNode(nested); });
|
||||
let id = '';
|
||||
h.act(() => { id = insertSection(h.query, h.actions, hero); });
|
||||
expect(rootKids()).toEqual([a, id, b]);
|
||||
});
|
||||
|
||||
test('appends when nothing is selected', () => {
|
||||
h = renderEditorHarness();
|
||||
const a = addBlank();
|
||||
let id = '';
|
||||
h.act(() => { id = insertSection(h.query, h.actions, hero); });
|
||||
expect(rootKids()).toEqual([a, id]);
|
||||
});
|
||||
|
||||
test('seed hero is token-based: no seed hex in the tree or the exported body', () => {
|
||||
expect(JSON.stringify(hero.tree)).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
|
||||
h = renderEditorHarness();
|
||||
let id = '';
|
||||
h.act(() => { id = insertSection(h.query, h.actions, hero); });
|
||||
const html = exportBodyHtml(h.getSerialized()).html;
|
||||
expect(html).toContain('Welcome to Your Business');
|
||||
expect(html).toContain('var(--wsb-bg)');
|
||||
expect(html).not.toContain(DEFAULT_SITE_DESIGN.primaryColor);
|
||||
expect(nameOf(id)).toBe('Section');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeTreeToSerializedTree round trip', () => {
|
||||
test('saved section re-inserts with the same displayName sequence and no node_id', () => {
|
||||
h = renderEditorHarness();
|
||||
let id = '';
|
||||
h.act(() => { id = insertSection(h.query, h.actions, getSections().find((s) => s.id === 'services-grid')!); });
|
||||
const names = (root: string) => [root, ...h.query.node(root).decendants(true)].map(nameOf);
|
||||
const before = names(id);
|
||||
const ser = nodeTreeToSerializedTree(h.query, id);
|
||||
expect(JSON.stringify(ser)).not.toContain('node_id');
|
||||
const custom: CustomSection = { id: 'c1', name: 'Mine', family: 'custom', tree: ser, createdAt: 1 };
|
||||
const def = getSections([custom]).find((s) => s.id === 'c1')!;
|
||||
let id2 = '';
|
||||
h.act(() => { id2 = insertSection(h.query, h.actions, def); });
|
||||
expect(id2).not.toBe(id);
|
||||
expect(names(id2)).toEqual(before);
|
||||
expect(before).toContain('Features Grid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search / families', () => {
|
||||
test('family filter and keyword search', () => {
|
||||
expect(searchSections('', 'hero').every((s) => s.family === 'hero')).toBe(true);
|
||||
expect(searchSections('', 'hero').length).toBeGreaterThan(0);
|
||||
expect(searchSections('banner').map((s) => s.id)).toContain('hero-centered');
|
||||
expect(searchSections('banner', 'services')).toEqual([]);
|
||||
expect(searchSections('zzzz-nothing')).toEqual([]);
|
||||
});
|
||||
test('unique ids and known families', () => {
|
||||
const all = getSections();
|
||||
expect(new Set(all.map((s) => s.id)).size).toBe(all.length);
|
||||
const fam = new Set(SECTION_FAMILIES.map((f) => f.id));
|
||||
for (const s of all) expect(fam.has(s.family)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every built-in section (validates content tasks after merge)', () => {
|
||||
for (const def of getSections()) {
|
||||
test(`${def.id} flattens, exports and previews`, () => {
|
||||
expect(() => flattenTreeForCraft(templateComponentToTreeNode(def.tree))).not.toThrow();
|
||||
const doc = buildSectionPreviewDoc(def, { ...DEFAULT_SITE_DESIGN, primaryColor: '#123456' });
|
||||
expect(doc).toContain('--wsb-primary:#123456');
|
||||
expect(doc).not.toContain('Export failed');
|
||||
h = renderEditorHarness();
|
||||
h.act(() => { insertSection(h.query, h.actions, def); });
|
||||
const out = exportBodyHtml(h.getSerialized()).html;
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
expect(out).not.toContain('Export failed');
|
||||
h.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('buildSectionPreviewDoc', () => {
|
||||
test('carries the design tokens and the section heading text', () => {
|
||||
const design = { ...DEFAULT_SITE_DESIGN, primaryColor: '#abcdef' };
|
||||
const doc = buildSectionPreviewDoc(hero, design);
|
||||
expect(doc).toContain('--wsb-primary:#abcdef');
|
||||
expect(doc).toContain('Welcome to Your Business');
|
||||
expect(doc).not.toContain('<script');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
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): SectionDef[] {
|
||||
const extras = useProjectExtras();
|
||||
return useMemo(
|
||||
() => getSections(extras.get<CustomSection[]>(CUSTOM_SECTIONS_KEY, [])),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[extras, version],
|
||||
);
|
||||
}
|
||||
|
||||
/** 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; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to ABOUT_SECTIONS only (see hero.ts for the shape). */
|
||||
export const ABOUT_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
|
||||
/** Contact sections. Content tasks append to CONTACT_SECTIONS only. */
|
||||
export const CONTACT_SECTIONS: SectionDef[] = [
|
||||
{
|
||||
id: 'contact-form',
|
||||
name: 'Contact form',
|
||||
family: 'contact',
|
||||
keywords: ['form', 'message', 'enquiry', 'get in touch', 'email'],
|
||||
tree: {
|
||||
type: 'Section',
|
||||
props: { aiName: 'Contact', style: { padding: '72px 20px', backgroundColor: tokenRef('bg') } },
|
||||
children: [
|
||||
{ type: 'Heading', props: { text: 'Get in Touch', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '12px' } } },
|
||||
{ type: 'TextBlock', props: { text: 'Send us a message and we will reply as soon as we can.', style: { textAlign: 'center', color: tokenRef('muted'), marginBottom: '24px' } } },
|
||||
{ type: 'ContactForm', props: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to CTA_SECTIONS only (see hero.ts for the shape). */
|
||||
export const CTA_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to FAQ_SECTIONS only (see hero.ts for the shape). */
|
||||
export const FAQ_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to GALLERY_SECTIONS only (see hero.ts for the shape). */
|
||||
export const GALLERY_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
|
||||
/**
|
||||
* Hero sections. Shape reference for the content tasks: append to
|
||||
* HERO_SECTIONS only. Trees are token-based (no raw hex) so a section takes
|
||||
* on the site's palette and fonts, and set no `animation` (the thumbnail
|
||||
* iframe is script-less).
|
||||
*/
|
||||
export const HERO_SECTIONS: SectionDef[] = [
|
||||
{
|
||||
id: 'hero-centered',
|
||||
name: 'Centered hero',
|
||||
family: 'hero',
|
||||
keywords: ['headline', 'banner', 'intro', 'welcome', 'landing'],
|
||||
tree: {
|
||||
type: 'Section',
|
||||
props: { aiName: 'Hero', style: { padding: '96px 20px', backgroundColor: tokenRef('bg'), textAlign: 'center' } },
|
||||
children: [
|
||||
{ type: 'Heading', props: { text: 'Welcome to Your Business', level: 'h1', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '16px' } } },
|
||||
{ type: 'TextBlock', props: { text: 'Tell visitors in one or two sentences what you do and who you do it for.', style: { textAlign: 'center', maxWidth: '640px', margin: '0 auto 24px', color: tokenRef('muted'), lineHeight: '1.7' } } },
|
||||
{ type: 'ButtonLink', props: { text: 'Get in touch', href: 'contact' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to HOURS_SECTIONS only (see hero.ts for the shape). */
|
||||
export const HOURS_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
import { HERO_SECTIONS } from './hero';
|
||||
import { SERVICES_SECTIONS } from './services';
|
||||
import { ABOUT_SECTIONS } from './about';
|
||||
import { CTA_SECTIONS } from './cta';
|
||||
import { SOCIAL_PROOF_SECTIONS } from './social-proof';
|
||||
import { FAQ_SECTIONS } from './faq';
|
||||
import { PRICING_SECTIONS } from './pricing';
|
||||
import { TEAM_SECTIONS } from './team';
|
||||
import { CONTACT_SECTIONS } from './contact';
|
||||
import { HOURS_SECTIONS } from './hours';
|
||||
import { GALLERY_SECTIONS } from './gallery';
|
||||
import { LOCAL_SECTIONS } from './local';
|
||||
|
||||
export {
|
||||
HERO_SECTIONS, SERVICES_SECTIONS, ABOUT_SECTIONS, CTA_SECTIONS,
|
||||
SOCIAL_PROOF_SECTIONS, FAQ_SECTIONS, PRICING_SECTIONS, TEAM_SECTIONS,
|
||||
CONTACT_SECTIONS, HOURS_SECTIONS, GALLERY_SECTIONS, LOCAL_SECTIONS,
|
||||
};
|
||||
|
||||
/** Every built-in section, in gallery order. `LOCAL_SECTIONS` entries carry
|
||||
* one of the regular family ids (local-business flavoured content). */
|
||||
export const BUILTIN_SECTIONS: SectionDef[] = [
|
||||
...HERO_SECTIONS, ...SERVICES_SECTIONS, ...ABOUT_SECTIONS, ...CTA_SECTIONS,
|
||||
...SOCIAL_PROOF_SECTIONS, ...FAQ_SECTIONS, ...PRICING_SECTIONS, ...TEAM_SECTIONS,
|
||||
...CONTACT_SECTIONS, ...HOURS_SECTIONS, ...GALLERY_SECTIONS, ...LOCAL_SECTIONS,
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to LOCAL_SECTIONS only (see hero.ts for the shape). */
|
||||
export const LOCAL_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to PRICING_SECTIONS only (see hero.ts for the shape). */
|
||||
export const PRICING_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
|
||||
/** Services sections. Content tasks append to SERVICES_SECTIONS only. */
|
||||
export const SERVICES_SECTIONS: SectionDef[] = [
|
||||
{
|
||||
id: 'services-grid',
|
||||
name: 'Services grid',
|
||||
family: 'services',
|
||||
keywords: ['features', 'offerings', 'what we do', 'benefits'],
|
||||
tree: {
|
||||
type: 'Section',
|
||||
props: { aiName: 'Services', style: { padding: '72px 20px', backgroundColor: tokenRef('bg') } },
|
||||
children: [
|
||||
{ type: 'Heading', props: { text: 'Our Services', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '24px' } } },
|
||||
{
|
||||
type: 'FeaturesGrid',
|
||||
props: {
|
||||
features: [
|
||||
{ title: 'Service one', description: 'A short description of what this service includes.', icon: '⭐' },
|
||||
{ title: 'Service two', description: 'A short description of what this service includes.', icon: '🛠️' },
|
||||
{ title: 'Service three', description: 'A short description of what this service includes.', icon: '💬' },
|
||||
],
|
||||
style: { backgroundColor: tokenRef('bg') },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to SOCIAL_PROOF_SECTIONS only (see hero.ts for the shape). */
|
||||
export const SOCIAL_PROOF_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SectionDef } from '../catalog';
|
||||
|
||||
/** Content task appends to TEAM_SECTIONS only (see hero.ts for the shape). */
|
||||
export const TEAM_SECTIONS: SectionDef[] = [];
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SiteDesign } from '../state/SiteDesignContext';
|
||||
import { templateComponentToTreeNode } from '../templates/apply-template';
|
||||
import { flattenTreeForCraft } from '../utils/craft-tree';
|
||||
import { exportToHtml } from '../utils/html-export';
|
||||
import type { SectionDef } from './catalog';
|
||||
|
||||
/** Full HTML document of one section rendered with the site's own tokens and
|
||||
* fonts, for a sandboxed (script-less) `<iframe srcdoc>` thumbnail. Uses the
|
||||
* exact static export path, so it matches what publishing would produce. */
|
||||
export function buildSectionPreviewDoc(def: SectionDef, design: SiteDesign): string {
|
||||
const flat = flattenTreeForCraft(templateComponentToTreeNode(def.tree));
|
||||
const nodes: Record<string, unknown> = { ...flat.nodes };
|
||||
// exportToHtml walks from ROOT; hang the section under a synthetic one.
|
||||
const rootChildren = [flat.rootNodeId];
|
||||
(nodes[flat.rootNodeId] as any).parent = 'ROOT';
|
||||
nodes.ROOT = {
|
||||
type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container',
|
||||
custom: {}, hidden: false, parent: null, nodes: rootChildren, linkedNodes: {},
|
||||
};
|
||||
return exportToHtml(JSON.stringify(nodes), { design, includeFonts: true }).html;
|
||||
}
|
||||
@@ -2557,3 +2557,99 @@ body {
|
||||
}
|
||||
|
||||
.palette-dot:first-child { margin-left: 0; }
|
||||
|
||||
/* ---------- #52 Section library ---------- */
|
||||
.section-lib {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 16px;
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.section-lib-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.section-lib-head h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.section-lib-close { background: none; border: 0; color: var(--color-text-muted); cursor: pointer; font-size: 16px; }
|
||||
.section-lib-controls, .section-lib-save { display: flex; gap: 8px; align-items: center; }
|
||||
.section-lib-search {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.section-lib-save-btn {
|
||||
padding: 7px 12px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section-lib-save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.section-lib-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.section-lib-chip {
|
||||
padding: 4px 10px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.section-lib-chip.active { border-color: var(--color-accent); color: var(--color-text); background: var(--color-accent-subtle); }
|
||||
.section-lib-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
min-height: 120px;
|
||||
}
|
||||
.section-lib-card { position: relative; }
|
||||
.section-lib-pick {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.section-lib-pick:hover, .section-lib-pick:focus-visible { border-color: var(--color-accent); }
|
||||
.section-lib-thumb { position: relative; width: 100%; height: 150px; overflow: hidden; border-radius: 4px; background: #fff; }
|
||||
.section-lib-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1200px;
|
||||
height: 600px;
|
||||
border: 0;
|
||||
transform: scale(0.2);
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.section-lib-name { font-size: 12px; font-weight: 500; }
|
||||
.section-lib-delete {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-danger);
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.section-lib-empty { grid-column: 1 / -1; color: var(--color-text-muted); font-size: 13px; padding: 24px 0; text-align: center; }
|
||||
.block-browse-sections { display: block; width: calc(100% - 16px); margin: 0 8px 8px; }
|
||||
Reference in new issue
Block a user