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:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions

View File

@@ -0,0 +1,421 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface Testimonial {
quote: string;
name: string;
title: string;
rating: number;
}
interface TestimonialsProps {
testimonials?: Testimonial[];
layout?: 'grid' | 'single';
columns?: number;
style?: CSSProperties;
cardBg?: string;
starColor?: string;
}
const defaultTestimonials: Testimonial[] = [
{ quote: 'This product has completely transformed our workflow. Highly recommended for any team.', name: 'Sarah Johnson', title: 'Marketing Director', rating: 5 },
{ quote: 'Outstanding support and an incredibly intuitive interface. We saw results from day one.', name: 'Michael Chen', title: 'CTO, TechStart', rating: 5 },
{ quote: 'The best investment we have made this year. Simple, powerful, and reliable.', name: 'Emily Rodriguez', title: 'Founder, DesignLab', rating: 4 },
];
function renderStars(count: number, color: string): React.ReactNode {
return (
<div style={{ display: 'flex', gap: '2px', justifyContent: 'center', marginBottom: '12px' }}>
{[1, 2, 3, 4, 5].map((i) => (
<i
key={i}
className={`fa ${i <= count ? 'fa-star' : 'fa-star-o'}`}
style={{ color, fontSize: '14px' }}
/>
))}
</div>
);
}
function starsHtml(count: number, color: string): string {
const stars = [1, 2, 3, 4, 5].map((i) =>
`<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px"></i>`
).join('');
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px">${stars}</div>`;
}
export const Testimonials: UserComponent<TestimonialsProps> = ({
testimonials = defaultTestimonials,
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [currentIndex, setCurrentIndex] = useState(0);
const cardStyle: CSSProperties = {
backgroundColor: cardBg,
borderRadius: '12px',
padding: '32px 24px',
textAlign: 'center',
border: '1px solid #e2e8f0',
};
const renderCard = (t: Testimonial, i: number) => (
<div key={i} style={cardStyle}>
{renderStars(t.rating, starColor)}
<p style={{ fontSize: '15px', color: '#374151', lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
&ldquo;{t.quote}&rdquo;
</p>
<div style={{ fontWeight: '600', fontSize: '14px', color: '#18181b', fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
<div style={{ fontSize: '13px', color: '#64748b', fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
</div>
);
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
{layout === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '24px' }}>
{items.map((t, i) => renderCard(t, i))}
</div>
) : (
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
{renderCard(items[currentIndex] || items[0], currentIndex)}
{items.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '20px' }}>
<button
onClick={() => setCurrentIndex((prev) => (prev - 1 + items.length) % items.length)}
style={{
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#374151',
}}
>
<i className="fa fa-chevron-left" />
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{items.map((_, i) => (
<div
key={i}
onClick={() => setCurrentIndex(i)}
style={{
width: 8, height: 8, borderRadius: '50%', cursor: 'pointer',
backgroundColor: i === currentIndex ? '#3b82f6' : '#d1d5db',
}}
/>
))}
</div>
<button
onClick={() => setCurrentIndex((prev) => (prev + 1) % items.length)}
style={{
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#374151',
}}
>
<i className="fa fa-chevron-right" />
</button>
</div>
)}
</div>
)}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const TestimonialsSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TestimonialsProps,
}));
const items = props.testimonials || defaultTestimonials;
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateTestimonial = (index: number, field: keyof Testimonial, value: string | number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated[index] = { ...updated[index], [field]: value };
p.testimonials = updated;
});
};
const addTestimonial = () => {
setProp((p: TestimonialsProps) => {
p.testimonials = [...(p.testimonials || defaultTestimonials), { quote: 'Great experience!', name: 'New Person', title: 'Role', rating: 5 }];
});
};
const removeTestimonial = (index: number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated.splice(index, 1);
p.testimonials = updated;
});
};
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
const cardBgPresets = ['#f8fafc', '#ffffff', '#f1f5f9', '#e2e8f0', '#27272a', '#1e293b'];
const starColorPresets = ['#f59e0b', '#eab308', '#ef4444', '#3b82f6', '#10b981', '#8b5cf6'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'grid'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'grid' ? '#3b82f6' : '#27272a',
color: props.layout === 'grid' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Grid
</button>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'single'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'single' ? '#3b82f6' : '#27272a',
color: props.layout === 'single' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Single
</button>
</div>
</div>
{/* Columns (only for grid) */}
{props.layout === 'grid' && (
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{[1, 2, 3, 4].map((n) => (
<button
key={n}
onClick={() => setProp((p: TestimonialsProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.columns === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
)}
{/* Section background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Card background */}
<div>
<label style={labelStyle}>Card Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{cardBgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.cardBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.cardBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Star color */}
<div>
<label style={labelStyle}>Star Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{starColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.starColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.starColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Testimonials list */}
<div>
<label style={labelStyle}>Testimonials</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((t, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={t.name} onChange={(e) => updateTestimonial(i, 'name', e.target.value)} placeholder="Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeTestimonial(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<input type="text" value={t.title} onChange={(e) => updateTestimonial(i, 'title', e.target.value)} placeholder="Title/Role" style={inputStyle} />
<textarea
value={t.quote}
onChange={(e) => updateTestimonial(i, 'quote', e.target.value)}
placeholder="Quote..."
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: '#a1a1aa' }}>Rating:</span>
{[1, 2, 3, 4, 5].map((n) => (
<i
key={n}
className={`fa ${n <= t.rating ? 'fa-star' : 'fa-star-o'}`}
onClick={() => updateTestimonial(i, 'rating', n)}
style={{ color: '#f59e0b', cursor: 'pointer', fontSize: 14 }}
/>
))}
</div>
</div>
))}
</div>
<button
onClick={addTestimonial}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Testimonial
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Testimonials.craft = {
displayName: 'Testimonials',
props: {
testimonials: defaultTestimonials,
layout: 'grid',
columns: 3,
style: { backgroundColor: '#ffffff' },
cardBg: '#f8fafc',
starColor: '#f59e0b',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TestimonialsSettings,
},
};
/* ---------- HTML export ---------- */
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
testimonials = defaultTestimonials,
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
} = props;
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
const sectionStyle = cssPropsToString({
padding: '80px 20px',
backgroundColor: '#ffffff',
...style,
});
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
const cards = items.map((t) => {
return `<div style="${cardCss}">
${starsHtml(t.rating, starColor)}
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${esc(t.quote)}&rdquo;</p>
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${esc(t.name)}</div>
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${esc(t.title)}</div>
</div>`;
}).join('\n ');
if (layout === 'single') {
// For single layout, export as grid with 1 column (simpler static export)
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
${cards}
</div>
</section>`,
};
}
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
${cards}
</div>
</section>`,
};
};