Add Craft.js site builder (v2) - complete rebuild from GrapesJS
Rebuilt the visual site builder from scratch using Craft.js, React 18, and TypeScript. The new editor renders directly in the DOM (no iframe), supports 40+ components, multi-page with shared header/footer, 16 templates, full-spectrum color/gradient controls, custom head code injection, save/publish workflow, and auto-save. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
||||
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
||||
selectedId, nodeProps, typeName,
|
||||
}) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
|
||||
const setPropValue = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setStyleValue = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
// Categorize all props
|
||||
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
|
||||
const colorProps = allProps.filter(([key, val]) => typeof val === 'string' && /color/i.test(key));
|
||||
const boolProps = allProps.filter(([_, val]) => typeof val === 'boolean');
|
||||
const numberProps = allProps.filter(([key, val]) => typeof val === 'number' && !/color/i.test(key));
|
||||
const stringProps = allProps.filter(([key, val]) => typeof val === 'string' && !/color/i.test(key));
|
||||
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* String props */}
|
||||
{stringProps.length > 0 && (
|
||||
<CollapsibleSection title="Properties">
|
||||
{stringProps.map(([key, val]) => {
|
||||
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
|
||||
const isLong = String(val).length > 60 || key === 'description' || key === 'text' || key === 'content';
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
{isLong ? (
|
||||
<textarea value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Number props */}
|
||||
{numberProps.length > 0 && (
|
||||
<CollapsibleSection title="Numbers" defaultOpen={true}>
|
||||
{numberProps.map(([key, val]) => (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||
<input type="number" value={val as number} onChange={(e) => setPropValue(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Boolean props */}
|
||||
{boolProps.length > 0 && (
|
||||
<CollapsibleSection title="Options" defaultOpen={true}>
|
||||
{boolProps.map(([key, val]) => (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={val as boolean} onChange={(e) => setPropValue(key, e.target.checked)} />
|
||||
{key.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Color props */}
|
||||
{colorProps.length > 0 && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{colorProps.map(([key, val]) => (
|
||||
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropValue(key, v)} />
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Array props */}
|
||||
{arrayProps.map(([key, items]) => {
|
||||
const arrayItems = items as any[];
|
||||
const sampleItem = arrayItems[0] || {};
|
||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||
|
||||
return (
|
||||
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey={key}
|
||||
items={arrayItems}
|
||||
renderItem={(item: any, index: number) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(item)}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = e.target.value;
|
||||
props[key] = updated;
|
||||
});
|
||||
}}
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{itemFields.map((field) => {
|
||||
const fieldVal = item[field];
|
||||
if (typeof fieldVal === 'boolean') {
|
||||
return (
|
||||
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.checked };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} />
|
||||
{field}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (typeof fieldVal === 'number') {
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
<input type="number" value={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (/color/i.test(field) && typeof fieldVal === 'string') {
|
||||
return (
|
||||
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
|
||||
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const strVal = String(fieldVal ?? '');
|
||||
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
{isLongField ? (
|
||||
<textarea value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
|
||||
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Style controls */}
|
||||
<CollapsibleSection title="Style">
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setStyleValue('backgroundColor', v)} />
|
||||
</div>
|
||||
{/* Text color in style */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Color</SectionLabel>
|
||||
<ColorSwatchGrid colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v: string) => setStyleValue('color', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setStyleValue('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setStyleValue('textAlign', a)} title={a}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setStyleValue('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user