Files
site-builder/craft/src/panels/sections/SectionLibraryModal.tsx
T

219 lines
9.0 KiB
TypeScript
Raw Normal View History

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>
);
};