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,97 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
GradientSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
uploadToWhp,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- BACKGROUND SECTION ---------- */
|
||||
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp('bgImage', url);
|
||||
}, [setProp]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background Image */}
|
||||
<CollapsibleSection title="Background Image">
|
||||
{nodeProps.bgImage && (
|
||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
|
||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste image URL..."
|
||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Background Color */}
|
||||
<CollapsibleSection title="Background Color">
|
||||
<ColorPickerField label="Background" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Overlay */}
|
||||
<CollapsibleSection title="Overlay">
|
||||
<ColorPickerField label="Overlay Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}%</label>
|
||||
<input type="range" min={0} max={100} value={Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}
|
||||
onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value) / 100)} style={{ width: '100%' }} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Layout */}
|
||||
<CollapsibleSection title="Layout" defaultOpen={false}>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Inner Max Width</label>
|
||||
<input type="text" value={nodeProps.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp('innerMaxWidth', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
88
craft/src/panels/right/styles/ButtonStylePanel.tsx
Normal file
88
craft/src/panels/right/styles/ButtonStylePanel.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
RADIUS_PRESETS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
autoTextColor,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- BUTTON ---------- */
|
||||
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
const setButtonColor = useCallback(
|
||||
(bgColor: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = {
|
||||
...props.style,
|
||||
backgroundColor: bgColor,
|
||||
color: autoTextColor(bgColor),
|
||||
};
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Button Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={BG_COLORS}
|
||||
activeValue={style.backgroundColor as string}
|
||||
onSelect={setButtonColor}
|
||||
/>
|
||||
</div>
|
||||
<TextInputField
|
||||
label="Button Text"
|
||||
value={nodeProps.text || ''}
|
||||
placeholder="Click Me"
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.text = v; });
|
||||
}}
|
||||
/>
|
||||
<TextInputField
|
||||
label="Link URL"
|
||||
value={nodeProps.href || ''}
|
||||
placeholder="https://..."
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.href = v; });
|
||||
}}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={SPACING_PRESETS}
|
||||
activeValue={style.padding as string}
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
80
craft/src/panels/right/styles/ContainerStylePanel.tsx
Normal file
80
craft/src/panels/right/styles/ContainerStylePanel.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
GradientSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- CONTAINER / SECTION ---------- */
|
||||
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={BG_COLORS}
|
||||
activeValue={style.backgroundColor as string}
|
||||
onSelect={(v) => setPropStyle('backgroundColor', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background Gradient</SectionLabel>
|
||||
<GradientSwatchGrid
|
||||
activeValue={style.background as string}
|
||||
onSelect={(v) => setPropStyle('background', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={SPACING_PRESETS}
|
||||
activeValue={style.padding as string}
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
|
||||
onClick={() => setPropStyle('textAlign', a)}
|
||||
title={a}
|
||||
>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
139
craft/src/panels/right/styles/FormStylePanel.tsx
Normal file
139
craft/src/panels/right/styles/FormStylePanel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- FORM ---------- */
|
||||
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Form action/method */}
|
||||
{nodeProps.action !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Form Action URL</label>
|
||||
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.method !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Method</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['GET', 'POST'].map((m) => (
|
||||
<button key={m} onClick={() => setProp('method', m)} style={btnActiveStyle(nodeProps.method === m)}>{m}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field props */}
|
||||
{nodeProps.label !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Label</label>
|
||||
<input type="text" value={nodeProps.label || ''} onChange={(e) => setProp('label', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.placeholder !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input type="text" value={nodeProps.placeholder || ''} onChange={(e) => setProp('placeholder', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.name !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Field Name</label>
|
||||
<input type="text" value={nodeProps.name || ''} onChange={(e) => setProp('name', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.type !== undefined && typeof nodeProps.type === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Input Type</label>
|
||||
<select value={nodeProps.type} onChange={(e) => setProp('type', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
{['text', 'email', 'password', 'number', 'tel', 'url'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.required !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.required || false} onChange={(e) => setProp('required', e.target.checked)} />
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button text */}
|
||||
{nodeProps.text !== undefined && nodeProps.label === undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscribe form props */}
|
||||
{nodeProps.buttonText !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.placeholderText !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Placeholder Text</label>
|
||||
<input type="text" value={nodeProps.placeholderText || ''} onChange={(e) => setProp('placeholderText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.successMessage !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Success Message</label>
|
||||
<input type="text" value={nodeProps.successMessage || ''} onChange={(e) => setProp('successMessage', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
251
craft/src/panels/right/styles/GenericPropsEditor.tsx
Normal file
251
craft/src/panels/right/styles/GenericPropsEditor.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
269
craft/src/panels/right/styles/HeroStylePanel.tsx
Normal file
269
craft/src/panels/right/styles/HeroStylePanel.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
StylePanelProps,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
uploadToWhp,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- Asset Browser Inline ---------- */
|
||||
const AssetBrowser: React.FC<{
|
||||
filter: 'image' | 'video' | 'all';
|
||||
onSelect: (url: string) => void;
|
||||
}> = ({ filter, onSelect }) => {
|
||||
const [assets, setAssets] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const filtered = filter === 'all' ? data.assets : data.assets.filter((a: any) => {
|
||||
const t = (a.type || '').toLowerCase();
|
||||
return filter === 'image' ? t.startsWith('image') : t.startsWith('video');
|
||||
});
|
||||
setAssets(filtered);
|
||||
}
|
||||
} catch (e) { console.error('Load assets failed:', e); }
|
||||
setLoading(false);
|
||||
}, [filter]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!open) loadAssets();
|
||||
setOpen(!open);
|
||||
}, [open, loadAssets]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleToggle} style={{
|
||||
...btnActiveStyle(open), width: '100%', marginTop: 4,
|
||||
}}>
|
||||
<i className={`fa ${loading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} />
|
||||
{open ? 'Close' : 'Browse Assets'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ maxHeight: 160, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{assets.map((asset, i) => (
|
||||
<div key={i} onClick={() => { onSelect(asset.url); setOpen(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s', background: '#27272a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}>
|
||||
{(asset.type || '').startsWith('image') ? (
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 4 }}>
|
||||
<i className="fa fa-film" style={{ fontSize: 20, color: '#71717a' }} />
|
||||
<div style={{ fontSize: 8, color: '#71717a', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 70 }}>
|
||||
{asset.name?.replace(/^\d+_[a-f0-9]+_/, '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<p style={{ gridColumn: '1/-1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: 12, margin: 0 }}>
|
||||
No {filter} assets uploaded yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- HERO STYLE PANEL ---------- */
|
||||
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleUpload = useCallback(async (file: File, propKey: string) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp(propKey, url);
|
||||
}, [setProp]);
|
||||
|
||||
const bgType = nodeProps.bgType || 'color';
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollapsibleSection title="Content">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input type="text" value={nodeProps.heading || ''} onChange={(e) => setProp('heading', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Subtitle</label>
|
||||
<textarea value={nodeProps.subtitle || ''} onChange={(e) => setProp('subtitle', e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button URL</label>
|
||||
<input type="text" value={nodeProps.buttonHref || ''} onChange={(e) => setProp('buttonHref', e.target.value)} placeholder="#" style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Secondary Button</label>
|
||||
<input type="text" value={nodeProps.secondaryButtonText || ''} onChange={(e) => setProp('secondaryButtonText', e.target.value)} placeholder="Leave blank to hide" style={inputStyle} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Background">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
|
||||
<button key={t} onClick={() => setProp('bgType', t)} style={btnActiveStyle(bgType === t)}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bgType === 'color' && (
|
||||
<ColorPickerField label="Color" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
|
||||
)}
|
||||
|
||||
{bgType === 'gradient' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="From" value={nodeProps.bgGradientFrom || '#667eea'} onChange={(v) => setProp('bgGradientFrom', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="To" value={nodeProps.bgGradientTo || '#764ba2'} onChange={(v) => setProp('bgGradientTo', v)} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Angle: {nodeProps.bgGradientAngle || 135}°</label>
|
||||
<input type="range" min={0} max={360} value={nodeProps.bgGradientAngle || 135} onChange={(e) => setProp('bgGradientAngle', parseInt(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
{/* Gradient preview */}
|
||||
<div style={{ height: 24, borderRadius: 4, background: `linear-gradient(${nodeProps.bgGradientAngle || 135}deg, ${nodeProps.bgGradientFrom || '#667eea'}, ${nodeProps.bgGradientTo || '#764ba2'})`, border: '1px solid #3f3f46' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{bgType === 'image' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Image</label>
|
||||
{nodeProps.bgImage && (
|
||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
</div>
|
||||
<AssetBrowser filter="image" onSelect={(url) => setProp('bgImage', url)} />
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f, 'bgImage'); e.target.value = ''; }} />
|
||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste URL..."
|
||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bgType === 'video' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Video</label>
|
||||
{nodeProps.bgVideo && (
|
||||
<div style={{ marginBottom: 6, padding: 8, background: '#18181b', borderRadius: 4, border: '1px solid #3f3f46', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<i className="fa fa-film" style={{ color: '#3b82f6' }} />
|
||||
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{nodeProps.bgVideo.replace(/.*filename=/, '').replace(/^\d+_[a-f0-9]+_/, '') || nodeProps.bgVideo.split('/').pop()}
|
||||
</span>
|
||||
<button onClick={() => setProp('bgVideo', '')} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'video/*';
|
||||
input.onchange = () => { const f = input.files?.[0]; if (f) handleUpload(f, 'bgVideo'); };
|
||||
input.click();
|
||||
}} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload Video
|
||||
</button>
|
||||
</div>
|
||||
<AssetBrowser filter="video" onSelect={(url) => setProp('bgVideo', url)} />
|
||||
<input type="text" value={nodeProps.bgVideo || ''} placeholder="YouTube, Vimeo, or .mp4 URL"
|
||||
onChange={(e) => setProp('bgVideo', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Overlay ({nodeProps.overlayOpacity || 0}%)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={nodeProps.overlayColor || '#000000'} onChange={(e) => setProp('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Colors">
|
||||
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#ffffff'} onChange={(v) => setProp('textColor', v)} />
|
||||
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="Button BG" value={nodeProps.buttonBgColor || '#3b82f6'} onChange={(v) => setProp('buttonBgColor', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="Button Text" value={nodeProps.buttonTextColor || '#ffffff'} onChange={(v) => setProp('buttonTextColor', v)} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Layout">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Minimum Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
|
||||
<button key={h} onClick={() => setProp('minHeight', h)} style={btnActiveStyle(nodeProps.minHeight === h)}>
|
||||
{h === '100vh' ? 'Full' : h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Vertical</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['top', 'center', 'bottom'] as const).map((v) => (
|
||||
<button key={v} onClick={() => setProp('verticalAlign', v)} style={btnActiveStyle(nodeProps.verticalAlign === v)}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Text Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp('textAlign', a)} style={btnActiveStyle(nodeProps.textAlign === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
215
craft/src/panels/right/styles/ImageStylePanel.tsx
Normal file
215
craft/src/panels/right/styles/ImageStylePanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
uploadToWhp,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
||||
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
const [imgUrl, setImgUrl] = useState(nodeProps.src || '');
|
||||
|
||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg";
|
||||
const isPlaceholder = !nodeProps.src || nodeProps.src.startsWith('data:image/svg');
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = url; });
|
||||
setImgUrl(url);
|
||||
}
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
const maxWidthPresets = [
|
||||
{ label: '25%', value: '25%' },
|
||||
{ label: '50%', value: '50%' },
|
||||
{ label: '75%', value: '75%' },
|
||||
{ label: '100%', value: '100%' },
|
||||
];
|
||||
|
||||
const getFriendlyName = (src: string) => {
|
||||
const match = src.match(/filename=([^&]+)/);
|
||||
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
|
||||
return src.split('/').pop() || 'image';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Image source with upload/browse */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Image Source</SectionLabel>
|
||||
|
||||
{!isPlaceholder && nodeProps.src ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
||||
<button
|
||||
onClick={() => { actions.setProp(selectedId, (props: any) => { props.src = ''; }); setImgUrl(''); }}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(nodeProps.src || '')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleUpload(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||
Drop image here or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload + Browse buttons */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline asset browser grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = asset.url; });
|
||||
setImgUrl(asset.url);
|
||||
setShowBrowser(false);
|
||||
}}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input for advanced users */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div className="guided-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={imgUrl}
|
||||
placeholder="Or paste image URL..."
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<button
|
||||
className="preset-btn apply-btn"
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = imgUrl; });
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt Text */}
|
||||
<TextInputField
|
||||
label="Alt Text"
|
||||
value={nodeProps.alt || ''}
|
||||
placeholder="Describe the image..."
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.alt = v; });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Border Radius */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Width */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Max Width</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={maxWidthPresets}
|
||||
activeValue={style.maxWidth as string}
|
||||
onSelect={(v) => setPropStyle('maxWidth', v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
197
craft/src/panels/right/styles/MediaStylePanel.tsx
Normal file
197
craft/src/panels/right/styles/MediaStylePanel.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
|
||||
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Video URL */}
|
||||
{nodeProps.videoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Video URL</label>
|
||||
<input type="text" value={nodeProps.videoUrl || ''} onChange={(e) => setProp('videoUrl', e.target.value)} placeholder="YouTube, Vimeo, or .mp4 URL" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map URL */}
|
||||
{nodeProps.embedUrl !== undefined && nodeProps.videoUrl === undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Embed URL</label>
|
||||
<input type="text" value={nodeProps.embedUrl || ''} onChange={(e) => setProp('embedUrl', e.target.value)} placeholder="Google Maps embed URL" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map address */}
|
||||
{nodeProps.address !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Address</label>
|
||||
<input type="text" value={nodeProps.address || ''} onChange={(e) => setProp('address', e.target.value)} placeholder="123 Main St..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video options */}
|
||||
{nodeProps.autoplay !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.autoplay || false} onChange={(e) => setProp('autoplay', e.target.checked)} />
|
||||
Autoplay
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.loop !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.loop || false} onChange={(e) => setProp('loop', e.target.checked)} />
|
||||
Loop
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.controls !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.controls !== false} onChange={(e) => setProp('controls', e.target.checked)} />
|
||||
Show Controls
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery items */}
|
||||
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
|
||||
<CollapsibleSection title="Images">
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey="images"
|
||||
items={nodeProps.images}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{item.src !== undefined && (
|
||||
<input type="text" value={item.src || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.images || [])];
|
||||
updated[index] = { ...updated[index], src: e.target.value };
|
||||
props.images = updated;
|
||||
});
|
||||
}} placeholder="Image URL" style={smallInputStyle} />
|
||||
)}
|
||||
{item.caption !== undefined && (
|
||||
<input type="text" value={item.caption || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.images || [])];
|
||||
updated[index] = { ...updated[index], caption: e.target.value };
|
||||
props.images = updated;
|
||||
});
|
||||
}} placeholder="Caption" style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ src: '', caption: '' }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Slides */}
|
||||
{nodeProps.slides !== undefined && Array.isArray(nodeProps.slides) && (
|
||||
<CollapsibleSection title="Slides">
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey="slides"
|
||||
items={nodeProps.slides}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{item.heading !== undefined && (
|
||||
<input type="text" value={item.heading || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], heading: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Heading" style={smallInputStyle} />
|
||||
)}
|
||||
{item.text !== undefined && (
|
||||
<textarea value={item.text || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], text: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
)}
|
||||
{item.image !== undefined && (
|
||||
<input type="text" value={item.image || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], image: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Image URL" style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ heading: 'New Slide', text: '', image: '' }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{nodeProps.overlayColor !== undefined && (
|
||||
<CollapsibleSection title="Overlay" defaultOpen={false}>
|
||||
<ColorPickerField label="Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
|
||||
{nodeProps.overlayOpacity !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {nodeProps.overlayOpacity ?? 0}%</label>
|
||||
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity ?? 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
192
craft/src/panels/right/styles/NavStylePanel.tsx
Normal file
192
craft/src/panels/right/styles/NavStylePanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- NAV / MENU / LOGO ---------- */
|
||||
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const links: any[] = nodeProps.links || [];
|
||||
|
||||
const updateLink = useCallback((index: number, field: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.links = [...(props.links || []), { text: 'New Link', href: '#' }];
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removeLink = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated.splice(index, 1);
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
/* Detect standalone Logo vs Navbar/Menu */
|
||||
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Standalone Logo component settings */}
|
||||
{isStandaloneLogo && (
|
||||
<CollapsibleSection title="Logo" defaultOpen={true}>
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp('type', 'text')}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'text' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'text' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'text' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-font" style={{ marginRight: 4 }} />Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp('type', 'image')}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'image' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'image' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'image' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-image" style={{ marginRight: 4 }} />Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{nodeProps.type === 'text' && (
|
||||
<>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Font Size</label>
|
||||
<input type="text" value={nodeProps.fontSize || '20px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Font Weight</label>
|
||||
<select value={nodeProps.fontWeight || '700'} onChange={(e) => setProp('fontWeight', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
<option value="300">Light</option>
|
||||
<option value="400">Normal</option>
|
||||
<option value="500">Medium</option>
|
||||
<option value="600">Semi</option>
|
||||
<option value="700">Bold</option>
|
||||
<option value="800">Extra Bold</option>
|
||||
</select>
|
||||
</div>
|
||||
<ColorPickerField label="Text Color" value={nodeProps.color || '#1f2937'} onChange={(v) => setProp('color', v)} />
|
||||
</>
|
||||
)}
|
||||
{nodeProps.type === 'image' && (
|
||||
<>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Image URL</label>
|
||||
<input type="text" value={nodeProps.imageSrc || ''} onChange={(e) => setProp('imageSrc', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Image Width</label>
|
||||
<input type="text" value={nodeProps.imageWidth || '120px'} onChange={(e) => setProp('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input type="text" value={nodeProps.href || '/'} onChange={(e) => setProp('href', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Navbar Logo settings */}
|
||||
{nodeProps.logoText !== undefined && (
|
||||
<CollapsibleSection title="Logo">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input type="text" value={nodeProps.logoText || ''} onChange={(e) => setProp('logoText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{nodeProps.logoImage !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Image URL</label>
|
||||
<input type="text" value={nodeProps.logoImage || ''} onChange={(e) => setProp('logoImage', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.logoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Link URL</label>
|
||||
<input type="text" value={nodeProps.logoUrl || ''} onChange={(e) => setProp('logoUrl', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Links (not shown for standalone Logo) */}
|
||||
{!isStandaloneLogo && (
|
||||
<CollapsibleSection title="Links">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={link.text || ''} onChange={(e) => updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
|
||||
<button onClick={() => removeLink(i)} style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={link.href || ''} onChange={(e) => updateLink(i, 'href', e.target.value)} placeholder="URL" style={{ ...smallInputStyle, color: '#71717a' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addLink} style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}>
|
||||
+ Add Link
|
||||
</button>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Colors (not shown for standalone Logo - it has its own color picker) */}
|
||||
{!isStandaloneLogo && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{nodeProps.backgroundColor !== undefined && (
|
||||
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} />
|
||||
)}
|
||||
{nodeProps.textColor !== undefined && (
|
||||
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
|
||||
)}
|
||||
{nodeProps.ctaColor !== undefined && (
|
||||
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Style overrides */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={(nodeProps.style || {}).padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
210
craft/src/panels/right/styles/PricingStylePanel.tsx
Normal file
210
craft/src/panels/right/styles/PricingStylePanel.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
StylePanelProps,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
const bulletOptions = [
|
||||
{ label: '✓', value: 'check' },
|
||||
{ label: '●', value: 'dot' },
|
||||
{ label: '→', value: 'arrow' },
|
||||
{ label: '★', value: 'star' },
|
||||
{ label: '—', value: 'dash' },
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
const bulletChar: Record<string, string> = {
|
||||
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
|
||||
};
|
||||
|
||||
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const [expandedPlan, setExpandedPlan] = useState<number>(0);
|
||||
|
||||
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
|
||||
const currentBullet = nodeProps.bulletType || 'check';
|
||||
|
||||
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated[planIndex] = { ...updated[planIndex], [field]: value };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addPlan = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated.push({
|
||||
name: 'New Plan',
|
||||
price: '$0',
|
||||
period: '/month',
|
||||
features: ['Feature 1'],
|
||||
buttonText: 'Choose Plan',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
});
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removePlan = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated.splice(index, 1);
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addFeature = useCallback((planIndex: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features.push('New feature');
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const updateFeature = useCallback((planIndex: number, featureIndex: number, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features[featureIndex] = value;
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removeFeature = useCallback((planIndex: number, featureIndex: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features.splice(featureIndex, 1);
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bullet type */}
|
||||
<CollapsibleSection title="Bullet Style">
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bulletOptions.map((b) => (
|
||||
<button key={b.value} onClick={() => actions.setProp(selectedId, (p: any) => { p.bulletType = b.value; })}
|
||||
style={{ ...btnActiveStyle(currentBullet === b.value), flex: 1, fontSize: 14 }}>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Plans */}
|
||||
<CollapsibleSection title={`Plans (${plans.length})`}>
|
||||
{plans.map((plan, i) => {
|
||||
const isExpanded = expandedPlan === i;
|
||||
const features: string[] = Array.isArray(plan.features) ? plan.features : [];
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
marginBottom: 8, background: '#18181b', borderRadius: 6,
|
||||
border: plan.isFeatured ? '1px solid #3b82f6' : '1px solid #27272a',
|
||||
}}>
|
||||
{/* Plan header - click to expand */}
|
||||
<div onClick={() => setExpandedPlan(isExpanded ? -1 : i)} style={{
|
||||
padding: '8px 10px', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7' }}>
|
||||
{plan.name || 'Plan'} {plan.isFeatured && <span style={{ fontSize: 9, background: '#3b82f6', color: '#fff', padding: '1px 5px', borderRadius: 3, marginLeft: 4 }}>Featured</span>}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#71717a' }}>{plan.price}</span>
|
||||
<i className={`fa fa-chevron-${isExpanded ? 'up' : 'down'}`} style={{ fontSize: 10, color: '#71717a' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded plan settings */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '0 10px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Name</label>
|
||||
<input type="text" value={plan.name || ''} onChange={(e) => updatePlan(i, 'name', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Price</label>
|
||||
<input type="text" value={plan.price || ''} onChange={(e) => updatePlan(i, 'price', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Period</label>
|
||||
<input type="text" value={plan.period || ''} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Button Text</label>
|
||||
<input type="text" value={plan.buttonText || ''} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Button URL</label>
|
||||
<input type="text" value={plan.buttonHref || ''} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!plan.isFeatured} onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)} />
|
||||
Featured (highlighted)
|
||||
</label>
|
||||
|
||||
{/* Features list */}
|
||||
<div>
|
||||
<label style={{ fontSize: 9, color: '#52525b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Features ({features.length})</span>
|
||||
<button onClick={() => addFeature(i)} style={{ fontSize: 9, background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 3, padding: '2px 6px', cursor: 'pointer' }}>
|
||||
+ Add
|
||||
</button>
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4 }}>
|
||||
{features.map((feat, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#10b981', width: 14, textAlign: 'center' }}>{bulletChar[currentBullet] || '✓'}</span>
|
||||
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...smallInputStyle, flex: 1 }} />
|
||||
<button onClick={() => removeFeature(i, fi)} style={{ fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, padding: '1px 4px', cursor: 'pointer', lineHeight: 1 }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove plan */}
|
||||
{plans.length > 1 && (
|
||||
<button onClick={() => removePlan(i)} style={{ fontSize: 10, background: 'none', color: '#ef4444', border: '1px solid #ef4444', borderRadius: 4, padding: '3px 8px', cursor: 'pointer', marginTop: 4 }}>
|
||||
Remove Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button onClick={addPlan} style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', marginTop: 4 }}>
|
||||
+ Add Plan
|
||||
</button>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Colors */}
|
||||
<CollapsibleSection title="Colors" defaultOpen={false}>
|
||||
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
239
craft/src/panels/right/styles/SectionTypePanel.tsx
Normal file
239
craft/src/panels/right/styles/SectionTypePanel.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
||||
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
// Find all string/number/boolean props
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
const scalarProps = Object.entries(nodeProps).filter(
|
||||
([key, val]) => !SKIP_PROPS.has(key) && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean')
|
||||
);
|
||||
const colorProps = scalarProps.filter(([key]) => /color/i.test(key));
|
||||
const otherScalarProps = scalarProps.filter(([key]) => !/color/i.test(key));
|
||||
const arrayProps = Object.entries(nodeProps).filter(([key, val]) => !SKIP_PROPS.has(key) && Array.isArray(val));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Content props */}
|
||||
{otherScalarProps.length > 0 && (
|
||||
<CollapsibleSection title="Content">
|
||||
{otherScalarProps.map(([key, val]) => {
|
||||
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
|
||||
if (typeof val === 'boolean') {
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={val} onChange={(e) => setProp(key, e.target.checked)} />
|
||||
{humanLabel}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof val === 'number') {
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
<input type="number" value={val} onChange={(e) => setProp(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// String - use textarea for long values
|
||||
const isLong = String(val).length > 60;
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
{isLong ? (
|
||||
<textarea value={String(val)} onChange={(e) => setProp(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={String(val)} onChange={(e) => setProp(key, e.target.value)} style={inputStyle} />
|
||||
)}
|
||||
</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) => setProp(key, v)} />
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Array props (features, items, plans, testimonials, etc.) */}
|
||||
{arrayProps.map(([key, items]) => {
|
||||
const arrayItems = items as any[];
|
||||
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
// color fields
|
||||
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>
|
||||
);
|
||||
}
|
||||
// long text
|
||||
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 */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('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={() => setPropStyle('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) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
177
craft/src/panels/right/styles/SocialStylePanel.tsx
Normal file
177
craft/src/panels/right/styles/SocialStylePanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
|
||||
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Social Links list */}
|
||||
{nodeProps.links !== undefined && Array.isArray(nodeProps.links) && (
|
||||
<CollapsibleSection title="Links">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(nodeProps.links || []).map((link: any, i: number) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa', width: 60, textTransform: 'capitalize' }}>{link.platform || 'link'}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={link.url || ''}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated[i] = { ...updated[i], url: e.target.value };
|
||||
props.links = updated;
|
||||
});
|
||||
}}
|
||||
placeholder="URL"
|
||||
style={{ ...smallInputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated.splice(i, 1);
|
||||
props.links = updated;
|
||||
});
|
||||
}}
|
||||
style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) return;
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.links = [...(props.links || []), { platform: e.target.value, url: '#' }];
|
||||
});
|
||||
e.target.value = '';
|
||||
}}
|
||||
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">+ Add Platform...</option>
|
||||
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp'].map((p) => (
|
||||
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Icon name */}
|
||||
{nodeProps.iconName !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon Name</label>
|
||||
<input type="text" value={nodeProps.iconName || ''} onChange={(e) => setProp('iconName', e.target.value)} placeholder="fa-star" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.icon !== undefined && typeof nodeProps.icon === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon</label>
|
||||
<input type="text" value={nodeProps.icon || ''} onChange={(e) => setProp('icon', e.target.value)} placeholder="fa-star or emoji" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star rating */}
|
||||
{nodeProps.rating !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Rating: {nodeProps.rating || 0}</label>
|
||||
<input type="range" min={0} max={5} step={0.5} value={nodeProps.rating || 0} onChange={(e) => setProp('rating', parseFloat(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.maxStars !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Max Stars</label>
|
||||
<input type="number" min={1} max={10} value={nodeProps.maxStars || 5} onChange={(e) => setProp('maxStars', parseInt(e.target.value) || 5)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
{nodeProps.iconColor !== undefined && (
|
||||
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
|
||||
)}
|
||||
{nodeProps.iconBgColor !== undefined && (
|
||||
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setProp('iconBgColor', v)} />
|
||||
)}
|
||||
{nodeProps.starColor !== undefined && (
|
||||
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setProp('starColor', v)} />
|
||||
)}
|
||||
{nodeProps.color !== undefined && typeof nodeProps.color === 'string' && (
|
||||
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setProp('color', v)} />
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
{nodeProps.iconSize !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon Size</label>
|
||||
<input type="text" value={nodeProps.iconSize || '24px'} onChange={(e) => setProp('iconSize', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.size !== undefined && typeof nodeProps.size === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<input type="text" value={nodeProps.size || '24px'} onChange={(e) => setProp('size', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alignment */}
|
||||
{nodeProps.alignment !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle(nodeProps.alignment === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
81
craft/src/panels/right/styles/TextStylePanel.tsx
Normal file
81
craft/src/panels/right/styles/TextStylePanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
FONT_FAMILIES,
|
||||
TEXT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- TEXT ---------- */
|
||||
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={TEXT_COLORS}
|
||||
activeValue={style.color as string}
|
||||
onSelect={(v) => setPropStyle('color', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Font Family</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={FONT_FAMILIES}
|
||||
activeValue={style.fontFamily as string}
|
||||
onSelect={(v) => setPropStyle('fontFamily', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Size</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={TEXT_SIZES}
|
||||
activeValue={style.fontSize as string}
|
||||
onSelect={(v) => setPropStyle('fontSize', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Font Weight</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={FONT_WEIGHTS}
|
||||
activeValue={String(style.fontWeight || '')}
|
||||
onSelect={(v) => setPropStyle('fontWeight', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
|
||||
onClick={() => setPropStyle('textAlign', a)}
|
||||
title={a}
|
||||
>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
13
craft/src/panels/right/styles/index.ts
Normal file
13
craft/src/panels/right/styles/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { TextStylePanel } from './TextStylePanel';
|
||||
export { ButtonStylePanel } from './ButtonStylePanel';
|
||||
export { ImageStylePanel } from './ImageStylePanel';
|
||||
export { ContainerStylePanel } from './ContainerStylePanel';
|
||||
export { HeroStylePanel } from './HeroStylePanel';
|
||||
export { NavStylePanel } from './NavStylePanel';
|
||||
export { MediaStylePanel } from './MediaStylePanel';
|
||||
export { FormStylePanel } from './FormStylePanel';
|
||||
export { SocialStylePanel } from './SocialStylePanel';
|
||||
export { SectionTypePanel } from './SectionTypePanel';
|
||||
export { PricingStylePanel } from './PricingStylePanel';
|
||||
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
|
||||
export { GenericPropsEditor } from './GenericPropsEditor';
|
||||
322
craft/src/panels/right/styles/shared.tsx
Normal file
322
craft/src/panels/right/styles/shared.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
GRADIENTS,
|
||||
} from '../../../constants/presets';
|
||||
|
||||
/* ---------- Helper: auto text color for bg ---------- */
|
||||
export function autoTextColor(bg: string): string {
|
||||
if (bg.startsWith('#')) {
|
||||
const hex = bg.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#18181b' : '#ffffff';
|
||||
}
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
/* ---------- Helper: upload to WHP ---------- */
|
||||
export async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/* ---------- Shared inline styles ---------- */
|
||||
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
|
||||
export const inputStyle: CSSProperties = { width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' };
|
||||
export const smallInputStyle: CSSProperties = { ...inputStyle, fontSize: 11, padding: '3px 6px' };
|
||||
export const btnActiveStyle = (active: boolean): CSSProperties => ({
|
||||
flex: 1, padding: '5px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
export const sectionGap: CSSProperties = { marginBottom: 14 };
|
||||
|
||||
/* ---------- Reusable sub-components ---------- */
|
||||
|
||||
interface SectionLabelProps { children: React.ReactNode; }
|
||||
export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
|
||||
<label className="guided-section-label">{children}</label>
|
||||
);
|
||||
|
||||
interface ColorSwatchGridProps {
|
||||
colors: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect }) => (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={activeValue || '#000000'}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={activeValue || ''}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
placeholder="#000000"
|
||||
style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }}
|
||||
/>
|
||||
</div>
|
||||
<div className="preset-grid">
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
className={`preset-swatch ${activeValue === c.value ? 'active' : ''}`}
|
||||
style={{ background: c.value }}
|
||||
onClick={() => onSelect(c.value)}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PresetButtonGridProps {
|
||||
presets: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
|
||||
<div className="preset-grid">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
|
||||
onClick={() => onSelect(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface GradientSwatchGridProps {
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
|
||||
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
|
||||
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
|
||||
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8})/);
|
||||
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
|
||||
return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
}
|
||||
|
||||
export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeValue, onSelect }) => {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const parsed = parseGradient(activeValue);
|
||||
const [customFrom, setCustomFrom] = useState(parsed.from);
|
||||
const [customTo, setCustomTo] = useState(parsed.to);
|
||||
const [customAngle, setCustomAngle] = useState(parsed.angle);
|
||||
|
||||
const applyCustomGradient = (from: string, to: string, angle: number) => {
|
||||
setCustomFrom(from);
|
||||
setCustomTo(to);
|
||||
setCustomAngle(angle);
|
||||
onSelect(`linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Custom gradient builder toggle */}
|
||||
<button
|
||||
onClick={() => setShowCustom(!showCustom)}
|
||||
style={{
|
||||
width: '100%', padding: '5px 8px', fontSize: 11, marginBottom: 6,
|
||||
background: showCustom ? '#3b82f6' : '#27272a', color: showCustom ? '#fff' : '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<i className={`fa fa-${showCustom ? 'chevron-down' : 'sliders'}`} style={{ fontSize: 10 }} />
|
||||
Custom Gradient
|
||||
</button>
|
||||
{showCustom && (
|
||||
<div style={{ padding: 8, background: '#1e1e22', borderRadius: 6, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>From</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||||
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
<input type="text" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||||
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>To</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||||
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
<input type="text" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||||
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>Angle: {customAngle}°</label>
|
||||
<input type="range" min={0} max={360} value={customAngle} onChange={(e) => applyCustomGradient(customFrom, customTo, parseInt(e.target.value))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
{/* Live preview */}
|
||||
<div style={{ height: 20, borderRadius: 4, marginTop: 6, background: `linear-gradient(${customAngle}deg, ${customFrom}, ${customTo})`, border: '1px solid #3f3f46' }} />
|
||||
</div>
|
||||
)}
|
||||
{/* Preset swatches */}
|
||||
<div className="preset-grid gradient-grid">
|
||||
{GRADIENTS.map((g) => (
|
||||
<button
|
||||
key={g.label}
|
||||
className={`preset-swatch gradient-swatch ${activeValue === g.value ? 'active' : ''}`}
|
||||
style={{ background: g.value === 'none' ? '#27272a' : g.value }}
|
||||
onClick={() => onSelect(g.value)}
|
||||
title={g.label}
|
||||
>
|
||||
{g.value === 'none' ? '\u00D7' : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextInputFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const TextInputField: React.FC<TextInputFieldProps> = ({ label, value, placeholder, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Color picker with hex input ---------- */
|
||||
interface ColorPickerFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange }) => (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value || '#000000'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Collapsible section ---------- */
|
||||
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #2d2d3a', paddingTop: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', background: 'none', border: 'none', color: '#a1a1aa', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
<i className={`fa fa-chevron-${open ? 'down' : 'right'}`} style={{ fontSize: 8, width: 10 }} />
|
||||
{title}
|
||||
</button>
|
||||
{open && <div style={{ paddingTop: 8 }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- StylePanelProps interface ---------- */
|
||||
export interface StylePanelProps {
|
||||
selectedId: string;
|
||||
nodeProps: Record<string, any>;
|
||||
}
|
||||
|
||||
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
|
||||
interface ArrayPropEditorProps {
|
||||
selectedId: string;
|
||||
propKey: string;
|
||||
items: any[];
|
||||
renderItem: (item: any, index: number) => React.ReactNode;
|
||||
emptyItem: any;
|
||||
}
|
||||
export const ArrayPropEditor: React.FC<ArrayPropEditorProps> = ({ selectedId, propKey, items, renderItem, emptyItem }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props[propKey] = [...(props[propKey] || []), typeof emptyItem === 'object' ? { ...emptyItem } : emptyItem];
|
||||
});
|
||||
}, [actions, selectedId, propKey, emptyItem]);
|
||||
|
||||
const removeItem = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[propKey] || [])];
|
||||
updated.splice(index, 1);
|
||||
props[propKey] = updated;
|
||||
});
|
||||
}, [actions, selectedId, propKey]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
style={{ position: 'absolute', top: 4, right: 4, padding: '1px 5px', fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', zIndex: 1 }}
|
||||
title="Remove"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
{renderItem(item, i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user