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,141 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export interface SpacingValue {
|
||||
top: string;
|
||||
right: string;
|
||||
bottom: string;
|
||||
left: string;
|
||||
}
|
||||
|
||||
interface SpacingInputProps {
|
||||
label: string;
|
||||
value: SpacingValue;
|
||||
onChange: (value: SpacingValue) => void;
|
||||
}
|
||||
|
||||
const UNITS = ['px', 'em', '%'] as const;
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||||
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
|
||||
};
|
||||
|
||||
const sideLabel: React.CSSProperties = {
|
||||
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
function parseValue(val: string): { num: string; unit: string } {
|
||||
const match = val.match(/^(-?\d*\.?\d+)\s*(px|em|%|rem)?$/);
|
||||
if (match) return { num: match[1], unit: match[2] || 'px' };
|
||||
return { num: val.replace(/[^0-9.-]/g, '') || '0', unit: 'px' };
|
||||
}
|
||||
|
||||
export const SpacingInput: React.FC<SpacingInputProps> = ({ label, value, onChange }) => {
|
||||
const [linked, setLinked] = useState(false);
|
||||
const [unit, setUnit] = useState<string>(() => parseValue(value.top).unit || 'px');
|
||||
|
||||
const handleSideChange = (side: keyof SpacingValue, raw: string) => {
|
||||
const numericPart = raw.replace(/[^0-9.-]/g, '');
|
||||
const newVal = numericPart ? `${numericPart}${unit}` : '0';
|
||||
|
||||
if (linked) {
|
||||
onChange({ top: newVal, right: newVal, bottom: newVal, left: newVal });
|
||||
} else {
|
||||
onChange({ ...value, [side]: newVal });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnitChange = (newUnit: string) => {
|
||||
setUnit(newUnit);
|
||||
// Re-apply current numeric values with new unit
|
||||
const updated: SpacingValue = { top: '', right: '', bottom: '', left: '' };
|
||||
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
|
||||
const { num } = parseValue(value[side]);
|
||||
updated[side] = num && num !== '0' ? `${num}${newUnit}` : '0';
|
||||
}
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const sides: { key: keyof SpacingValue; label: string }[] = [
|
||||
{ key: 'top', label: 'T' },
|
||||
{ key: 'right', label: 'R' },
|
||||
{ key: 'bottom', label: 'B' },
|
||||
{ key: 'left', label: 'L' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>{label}</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Unit selector */}
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{UNITS.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => handleUnitChange(u)}
|
||||
style={{
|
||||
padding: '1px 5px', fontSize: 9, borderRadius: 3, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: unit === u ? '#3b82f6' : '#27272a',
|
||||
color: unit === u ? '#fff' : '#71717a',
|
||||
}}
|
||||
>{u}</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Link toggle */}
|
||||
<button
|
||||
onClick={() => setLinked(!linked)}
|
||||
title={linked ? 'Unlink sides' : 'Link all sides'}
|
||||
style={{
|
||||
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: linked ? '#3b82f6' : '#27272a',
|
||||
color: linked ? '#fff' : '#71717a',
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${linked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
|
||||
{sides.map((s) => (
|
||||
<div key={s.key}>
|
||||
<input
|
||||
type="text"
|
||||
value={parseValue(value[s.key]).num}
|
||||
onChange={(e) => handleSideChange(s.key, e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={sideLabel}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Parse a CSS shorthand like "10px 20px 10px 20px" or "10px" into SpacingValue */
|
||||
export function parseSpacingShorthand(val: string | undefined): SpacingValue {
|
||||
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
|
||||
const parts = val.trim().split(/\s+/);
|
||||
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
|
||||
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
|
||||
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
|
||||
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
|
||||
}
|
||||
|
||||
/** Convert SpacingValue back to CSS shorthand */
|
||||
export function spacingToShorthand(val: SpacingValue): string {
|
||||
const { top, right, bottom, left } = val;
|
||||
if (top === right && right === bottom && bottom === left) return top || '0';
|
||||
if (top === bottom && right === left) return `${top} ${right}`;
|
||||
if (right === left) return `${top} ${right} ${bottom}`;
|
||||
return `${top} ${right} ${bottom} ${left}`;
|
||||
}
|
||||
Reference in New Issue
Block a user