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,423 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface ContactFormField {
|
||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface ContactFormProps {
|
||||
fields?: ContactFormField[];
|
||||
submitText?: string;
|
||||
submitColor?: string;
|
||||
formAction?: string;
|
||||
successMessage?: string;
|
||||
style?: CSSProperties;
|
||||
labelColor?: string;
|
||||
inputBg?: string;
|
||||
inputBorder?: string;
|
||||
}
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true },
|
||||
{ type: 'email', label: 'Email', name: 'email', placeholder: 'your@email.com', required: true },
|
||||
{ type: 'tel', label: 'Phone', name: 'phone', placeholder: '(555) 123-4567', required: false },
|
||||
{ type: 'textarea', label: 'Message', name: 'message', placeholder: 'How can we help you?', required: true },
|
||||
];
|
||||
|
||||
export const ContactForm: UserComponent<ContactFormProps> = ({
|
||||
fields = defaultFields,
|
||||
submitText = 'Send Message',
|
||||
submitColor = '#3b82f6',
|
||||
formAction = '#',
|
||||
successMessage = 'Thank you! We\'ll get back to you soon.',
|
||||
style = {},
|
||||
labelColor = '#374151',
|
||||
inputBg = '#ffffff',
|
||||
inputBorder = '#d1d5db',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const inputBaseStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: `1px solid ${inputBorder}`,
|
||||
borderRadius: '6px',
|
||||
backgroundColor: inputBg,
|
||||
color: '#1f2937',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
action={formAction}
|
||||
method="POST"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: labelColor }}>
|
||||
{field.label}
|
||||
{field.required && <span style={{ color: '#ef4444', marginLeft: '2px' }}>*</span>}
|
||||
</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
style={{ ...inputBaseStyle, resize: 'vertical' }}
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<select
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
style={{ ...inputBaseStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">{field.placeholder || 'Select...'}</option>
|
||||
{(field.options || []).map((opt, j) => (
|
||||
<option key={j} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={field.type}
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
style={inputBaseStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: submitColor,
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const fieldTypes: ContactFormField['type'][] = ['text', 'email', 'tel', 'textarea', 'select'];
|
||||
|
||||
const ContactFormSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ContactFormProps,
|
||||
}));
|
||||
|
||||
const fields = props.fields || defaultFields;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateField = (index: number, key: keyof ContactFormField, value: any) => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
const updated = [...(p.fields || defaultFields)];
|
||||
updated[index] = { ...updated[index], [key]: value };
|
||||
p.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
p.fields = [...(p.fields || defaultFields), { type: 'text', label: 'New Field', name: 'new_field', placeholder: '', required: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
const updated = [...(p.fields || defaultFields)];
|
||||
updated.splice(index, 1);
|
||||
p.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const submitColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Form Action */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.formAction || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.formAction = e.target.value; })}
|
||||
placeholder="https://... or /api/submit"
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.successMessage || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.successMessage = e.target.value; })}
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.submitText || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.submitText = e.target.value; })}
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{submitColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: ContactFormProps) => { p.submitColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.submitColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.labelColor || '#374151'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.labelColor = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Background */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Background</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.inputBg || '#ffffff'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBg = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Border */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Border Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.inputBorder || '#d1d5db'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBorder = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fields Editor */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Fields</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{fields.map((field, 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' }}>
|
||||
<select
|
||||
value={field.type}
|
||||
onChange={(e) => updateField(i, 'type', e.target.value)}
|
||||
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{fieldTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(i, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeField(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={field.name}
|
||||
onChange={(e) => updateField(i, 'name', e.target.value)}
|
||||
placeholder="name attr"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder}
|
||||
onChange={(e) => updateField(i, 'placeholder', e.target.value)}
|
||||
placeholder="Placeholder"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => updateField(i, 'required', e.target.checked)}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
{field.type === 'select' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Options (one per line)</label>
|
||||
<textarea
|
||||
value={(field.options || []).join('\n')}
|
||||
onChange={(e) => updateField(i, 'options', e.target.value.split('\n').filter((s: string) => s.trim()))}
|
||||
rows={3}
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addField}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContactForm.craft = {
|
||||
displayName: 'Contact Form',
|
||||
props: {
|
||||
fields: defaultFields,
|
||||
submitText: 'Send Message',
|
||||
submitColor: '#3b82f6',
|
||||
formAction: '#',
|
||||
successMessage: 'Thank you! We\'ll get back to you soon.',
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
labelColor: '#374151',
|
||||
inputBg: '#ffffff',
|
||||
inputBorder: '#d1d5db',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContactFormSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const formStyle = cssPropsToString({
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
...props.style,
|
||||
});
|
||||
const labelColor = props.labelColor || '#374151';
|
||||
const inputBg = props.inputBg || '#ffffff';
|
||||
const inputBorder = props.inputBorder || '#d1d5db';
|
||||
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
|
||||
|
||||
const fieldsHtml = (props.fields || defaultFields).map((field) => {
|
||||
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
|
||||
const labelHtml = `<label style="font-size:14px;font-weight:500;color:${labelColor}">${esc(field.label)}${reqStar}</label>`;
|
||||
const reqAttr = field.required ? ' required' : '';
|
||||
let inputHtml = '';
|
||||
if (field.type === 'textarea') {
|
||||
inputHtml = `<textarea name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
|
||||
} else if (field.type === 'select') {
|
||||
const opts = (field.options || []).map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
|
||||
inputHtml = `<select name="${esc(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${esc(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||
} else {
|
||||
inputHtml = `<input type="${field.type}" name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
|
||||
}
|
||||
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
padding: '12px 32px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: props.submitColor || '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${fieldsHtml}
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
|
||||
</form>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FormButtonProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const FormButton: UserComponent<FormButtonProps> = ({
|
||||
text = 'Submit',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(ref: HTMLButtonElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
type="submit"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: selected ? '2px' : '0',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FormButtonSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FormButtonProps,
|
||||
}));
|
||||
|
||||
const colorPresets = [
|
||||
{ bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
|
||||
{ bg: '#10b981', color: '#ffffff', label: 'Green' },
|
||||
{ bg: '#ef4444', color: '#ffffff', label: 'Red' },
|
||||
{ bg: '#f59e0b', color: '#18181b', label: 'Amber' },
|
||||
{ bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
|
||||
{ bg: '#18181b', color: '#ffffff', label: 'Dark' },
|
||||
];
|
||||
|
||||
const radiusPresets = ['0px', '4px', '6px', '8px', '9999px'];
|
||||
const widthPresets = ['auto', '100%'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: FormButtonProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setProp((p: FormButtonProps) => {
|
||||
p.style = { ...p.style, backgroundColor: preset.bg, color: preset.color };
|
||||
})}
|
||||
title={preset.label}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: preset.bg, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, borderRadius: r }; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{widthPresets.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, width: w }; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.width === w ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{w === 'auto' ? 'Auto' : 'Full Width'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormButton.craft = {
|
||||
displayName: 'Submit Button',
|
||||
props: {
|
||||
text: 'Submit',
|
||||
style: {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FormButtonSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FormButton as any).toHtml = (props: FormButtonProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '12px 32px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = (props.text || 'Submit').replace(/</g, '<').replace(/>/g, '>');
|
||||
return {
|
||||
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FormContainerProps {
|
||||
action?: string;
|
||||
method?: 'GET' | 'POST';
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
action = '#',
|
||||
method = 'POST',
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
action={action}
|
||||
method={method}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '24px',
|
||||
minHeight: '80px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Element
|
||||
id="form-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
|
||||
tag="div"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FormContainerSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FormContainerProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.action || ''}
|
||||
onChange={(e) => setProp((p: FormContainerProps) => { p.action = e.target.value; })}
|
||||
placeholder="https://... or /api/submit"
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Method</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['GET', 'POST'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setProp((p: FormContainerProps) => { p.method = m; })}
|
||||
style={{
|
||||
padding: '4px 12px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.method === m ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FormContainerProps) => { 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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormContainer.craft = {
|
||||
displayName: 'Form',
|
||||
props: {
|
||||
action: '#',
|
||||
method: 'POST',
|
||||
style: {
|
||||
padding: '24px',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e4e4e7',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FormContainerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '24px',
|
||||
...props.style,
|
||||
});
|
||||
return {
|
||||
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface InputFieldProps {
|
||||
label?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const InputField: UserComponent<InputFieldProps> = ({
|
||||
label = 'Label',
|
||||
type = 'text',
|
||||
name = 'field',
|
||||
placeholder = '',
|
||||
required = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
borderRadius: '4px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
|
||||
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #d4d4d8',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const InputFieldSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as InputFieldProps,
|
||||
}));
|
||||
|
||||
const typeOptions: InputFieldProps['type'][] = ['text', 'email', 'password', 'number', 'tel', 'url'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.label || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.label = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{typeOptions.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProp((p: InputFieldProps) => { p.type = t; })}
|
||||
style={{
|
||||
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.type === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.name || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.name = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.placeholder = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.required}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.required = e.target.checked; })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
InputField.craft = {
|
||||
displayName: 'Input',
|
||||
props: {
|
||||
label: 'Your Name',
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
placeholder: 'Enter your name',
|
||||
required: false,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: InputFieldSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<input type="${props.type || 'text'}" name="${esc(props.name || 'field')}" placeholder="${esc(props.placeholder || '')}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface SubscribeFormProps {
|
||||
heading?: string;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
buttonColor?: string;
|
||||
layout?: 'inline' | 'stacked';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
buttonText = 'Subscribe',
|
||||
buttonColor = '#3b82f6',
|
||||
layout = 'inline',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '40px 24px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{heading && (
|
||||
<h3 style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '20px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{heading}
|
||||
</h3>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: isInline ? 'row' : 'column',
|
||||
gap: isInline ? '0' : '12px',
|
||||
maxWidth: isInline ? '480px' : '360px',
|
||||
margin: '0 auto',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: isInline ? '8px 0 0 8px' : '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: buttonColor,
|
||||
border: 'none',
|
||||
borderRadius: isInline ? '0 8px 8px 0' : '8px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SubscribeFormSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SubscribeFormProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const buttonColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a', '#eff6ff', '#f0fdf4', '#fef3c7'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.heading = e.target.value; })}
|
||||
placeholder="Subscribe to our newsletter"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Placeholder */}
|
||||
<div>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.placeholder = e.target.value; })}
|
||||
placeholder="Enter your email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Button Text */}
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.buttonText = e.target.value; })}
|
||||
placeholder="Subscribe"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Layout</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'inline'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.layout || 'inline') === 'inline' ? '#3b82f6' : '#27272a',
|
||||
color: (props.layout || 'inline') === 'inline' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'stacked'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.layout === 'stacked' ? '#3b82f6' : '#27272a',
|
||||
color: props.layout === 'stacked' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Stacked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{buttonColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.buttonColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.buttonColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { 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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SubscribeForm.craft = {
|
||||
displayName: 'Subscribe Form',
|
||||
props: {
|
||||
heading: 'Subscribe to our newsletter',
|
||||
placeholder: 'Enter your email',
|
||||
buttonText: 'Subscribe',
|
||||
buttonColor: '#3b82f6',
|
||||
layout: 'inline',
|
||||
style: { backgroundColor: '#f8fafc' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SubscribeFormSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
buttonText = 'Subscribe',
|
||||
buttonColor = '#3b82f6',
|
||||
layout = 'inline',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
padding: '40px 24px',
|
||||
textAlign: 'center',
|
||||
...style,
|
||||
});
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${esc(heading)}</h3>`
|
||||
: '';
|
||||
|
||||
const formStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: isInline ? 'row' : 'column',
|
||||
gap: isInline ? '0' : '12px',
|
||||
maxWidth: isInline ? '480px' : '360px',
|
||||
margin: '0 auto',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
|
||||
const inputStyleStr = `flex:1;padding:12px 16px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${isInline ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
padding: '12px 24px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: buttonColor,
|
||||
border: 'none',
|
||||
borderRadius: isInline ? '0 8px 8px 0' : '8px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<input type="email" name="email" placeholder="${esc(placeholder)}" required style="${inputStyleStr}" />
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(buttonText)}</button>
|
||||
</form>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface TextareaFieldProps {
|
||||
label?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
||||
label = 'Message',
|
||||
name = 'message',
|
||||
placeholder = '',
|
||||
rows = 4,
|
||||
required = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
borderRadius: '4px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
|
||||
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
required={required}
|
||||
readOnly
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #d4d4d8',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TextareaFieldSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TextareaFieldProps,
|
||||
}));
|
||||
|
||||
const rowsPresets = [2, 3, 4, 6, 8];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.label || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.label = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.name || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.name = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.placeholder = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Rows</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{rowsPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: TextareaFieldProps) => { p.rows = r; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.rows === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.required}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.required = e.target.checked; })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
TextareaField.craft = {
|
||||
displayName: 'Textarea',
|
||||
props: {
|
||||
label: 'Message',
|
||||
name: 'message',
|
||||
placeholder: 'Enter your message',
|
||||
rows: 4,
|
||||
required: false,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TextareaFieldSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<textarea name="${esc(props.name || 'message')}" placeholder="${esc(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user