refactor(builder): remove dead component settings UI

Each component defined a .craft.related.settings panel that was never
rendered -- the right panel renders only GuidedStyles (per-type
*StylePanel components), never .related.settings. Removed all dead
settings components across every component, their settings-only helpers
(including the dead uploadToWhp/showBrowser/handleBrowse asset-browse
blocks in Logo/Navbar/VideoBlock/FeaturesGrid, and CtasEditor in
_cta-helpers), and dropped the now-empty related keys. Render output,
.craft props/rules, and toHtml statics are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:16:53 -07:00
parent 4674a99ec9
commit 65a10a1ef9
40 changed files with 10 additions and 6718 deletions
-147
View File
@@ -45,150 +45,6 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
);
};
/* ---------- Settings panel ---------- */
const ButtonLinkSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ButtonLinkProps,
}));
const colorPresets = [
{ bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
{ bg: '#10b981', color: '#ffffff', label: 'Green' },
{ bg: '#ef4444', color: '#ffffff', label: 'Red' },
{ bg: '#f59e0b', color: '#18181b', label: 'Amber' },
{ bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
{ bg: '#18181b', color: '#ffffff', label: 'Dark' },
{ bg: '#ffffff', color: '#18181b', label: 'White' },
{ bg: 'transparent', color: '#3b82f6', label: 'Ghost' },
];
const radiusPresets = ['0px', '4px', '8px', '12px', '9999px'];
const paddingPresets = ['8px 16px', '10px 20px', '12px 24px', '14px 32px', '16px 40px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: ButtonLinkProps) => { p.text = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Link URL</label>
<input
type="text"
value={props.href || ''}
onChange={(e) => setProp((p: ButtonLinkProps) => { p.href = e.target.value; })}
placeholder="https://..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Target</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['_self', '_blank'] as const).map((t) => (
<button
key={t}
onClick={() => setProp((p: ButtonLinkProps) => { p.target = t; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.target === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t === '_self' ? 'Same Tab' : 'New Tab'}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((preset) => (
<button
key={preset.label}
onClick={() => setProp((p: ButtonLinkProps) => {
p.style = {
...p.style,
backgroundColor: preset.bg,
color: preset.color,
border: preset.bg === 'transparent' ? `1px solid ${preset.color}` : 'none',
};
})}
title={preset.label}
style={{
width: 24, height: 24, borderRadius: 4,
border: preset.bg === 'transparent' ? `2px solid ${preset.color}` : '1px solid #3f3f46',
backgroundColor: preset.bg, cursor: 'pointer',
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{radiusPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, borderRadius: r }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{paddingPresets.map((p) => (
<button
key={p}
onClick={() => setProp((pr: ButtonLinkProps) => { pr.style = { ...pr.style, padding: p }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Font Size</label>
<input
type="text"
placeholder="e.g. 16px"
value={(props.style?.fontSize as string) || ''}
onChange={(e) => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, fontSize: e.target.value }; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ButtonLink.craft = {
@@ -212,9 +68,6 @@ ButtonLink.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ButtonLinkSettings,
},
};
/* ---------- HTML export ---------- */
-56
View File
@@ -34,59 +34,6 @@ export const Divider: UserComponent<DividerProps> = ({
);
};
/* ---------- Settings panel ---------- */
const DividerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as DividerProps,
}));
const colorPresets = ['#e4e4e7', '#d4d4d8', '#a1a1aa', '#3f3f46', '#18181b', '#3b82f6', '#ef4444', '#10b981'];
const thicknessPresets = ['1px', '2px', '3px', '4px', '6px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: DividerProps) => { p.color = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thickness</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{thicknessPresets.map((t) => (
<button
key={t}
onClick={() => setProp((p: DividerProps) => { p.thickness = t; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.thickness === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Divider.craft = {
@@ -101,9 +48,6 @@ Divider.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: DividerSettings,
},
};
/* ---------- HTML export ---------- */
-64
View File
@@ -56,67 +56,6 @@ export const Footer: UserComponent<FooterProps> = ({
);
};
/* ---------- Settings panel ---------- */
const FooterSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FooterProps,
}));
const bgPresets = ['#ffffff', '#f8fafc', '#18181b', '#0f172a', '#1e293b'];
const colorPresets = ['#18181b', '#3f3f46', '#71717a', '#a1a1aa', '#e4e4e7', '#ffffff'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Footer Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: FooterProps) => { p.text = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, color: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Footer.craft = {
@@ -135,9 +74,6 @@ Footer.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FooterSettings,
},
};
/* ---------- HTML export ---------- */
-76
View File
@@ -1,9 +1,6 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { TypographyControl } from '../../ui/TypographyControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
@@ -80,76 +77,6 @@ export const Heading: UserComponent<HeadingProps> = ({
});
};
/* ---------- Settings panel ---------- */
const HeadingSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HeadingProps,
}));
const levels: HeadingLevel[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
return (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Heading Level</label>
<div style={{ display: 'flex', gap: 4 }}>
{levels.map((l) => (
<button
key={l}
onClick={() => setProp((p: HeadingProps) => { p.level = l; })}
style={{
flex: 1, padding: '4px 0', borderRadius: 4, border: '1px solid #3f3f46', cursor: 'pointer',
background: props.level === l ? '#3b82f6' : '#27272a', color: props.level === l ? '#fff' : '#a1a1aa',
fontSize: 12, fontWeight: 600,
}}
>{l.toUpperCase()}</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: HeadingProps) => { p.text = e.target.value; })}
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 13 }}
/>
</div>
</div>
}
style={
<TypographyControl
style={props.style || {}}
onChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
/>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
cssId={props.cssId || ''}
onCssIdChange={(id) => setProp((p: HeadingProps) => { p.cssId = id; })}
cssClass={props.cssClass || ''}
onCssClassChange={(cls) => setProp((p: HeadingProps) => { p.cssClass = cls; })}
hideOnDesktop={props.hideOnDesktop}
onHideOnDesktopChange={(v) => setProp((p: HeadingProps) => { p.hideOnDesktop = v; })}
hideOnTablet={props.hideOnTablet}
onHideOnTabletChange={(v) => setProp((p: HeadingProps) => { p.hideOnTablet = v; })}
hideOnMobile={props.hideOnMobile}
onHideOnMobileChange={(v) => setProp((p: HeadingProps) => { p.hideOnMobile = v; })}
animation={props.animation}
onAnimationChange={(v) => setProp((p: HeadingProps) => { p.animation = v; })}
animationDelay={props.animationDelay}
onAnimationDelayChange={(v) => setProp((p: HeadingProps) => { p.animationDelay = v; })}
/>
}
/>
);
};
Heading.craft = {
displayName: 'Heading',
props: {
@@ -168,9 +95,6 @@ Heading.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: HeadingSettings,
},
};
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
-73
View File
@@ -49,76 +49,6 @@ export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {}
});
};
/* ---------- Settings panel ---------- */
const HtmlBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HtmlBlockProps,
}));
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div style={{
padding: '8px 10px',
background: '#44200a',
border: '1px solid #92400e',
borderRadius: 6,
fontSize: 11,
color: '#fbbf24',
lineHeight: 1.4,
}}>
This block renders raw HTML. Use with caution.
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>HTML Code</label>
<textarea
value={props.code || ''}
onChange={(e) => setProp((p: HtmlBlockProps) => { p.code = e.target.value; })}
placeholder="<div>Your HTML here...</div>"
rows={16}
style={{
width: '100%',
padding: '10px',
background: '#1a1a2e',
color: '#a5f3fc',
border: '1px solid #3f3f46',
borderRadius: 6,
fontSize: 12,
fontFamily: '"Source Code Pro", "Fira Code", monospace',
lineHeight: 1.5,
resize: 'vertical',
boxSizing: 'border-box',
whiteSpace: 'pre',
tabSize: 2,
}}
/>
</div>
{/* Outer container style */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['0px', '8px', '16px', '24px', '32px'].map((p) => (
<button
key={p}
onClick={() => setProp((pr: HtmlBlockProps) => { pr.style = { ...pr.style, padding: p }; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
HtmlBlock.craft = {
@@ -132,9 +62,6 @@ HtmlBlock.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: HtmlBlockSettings,
},
};
/* ---------- HTML export ---------- */
-182
View File
@@ -14,15 +14,6 @@ interface IconProps {
style?: CSSProperties;
}
const COMMON_ICONS = [
'fa-star', 'fa-heart', 'fa-check', 'fa-phone', 'fa-envelope',
'fa-map-marker', 'fa-globe', 'fa-facebook', 'fa-twitter', 'fa-instagram',
'fa-linkedin', 'fa-youtube', 'fa-github', 'fa-arrow-right', 'fa-arrow-down',
'fa-play', 'fa-search', 'fa-user', 'fa-lock', 'fa-cog',
'fa-home', 'fa-comment', 'fa-camera', 'fa-music', 'fa-shopping-cart',
'fa-calendar', 'fa-clock-o', 'fa-thumbs-up', 'fa-lightbulb-o', 'fa-rocket',
];
function getBgBorderRadius(shape: string): string {
if (shape === 'circle') return '50%';
if (shape === 'rounded') return '8px';
@@ -92,176 +83,6 @@ export const Icon: UserComponent<IconProps> = ({
);
};
/* ---------- Settings panel ---------- */
const IconSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as IconProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const sizePresets = ['24px', '32px', '48px', '64px'];
const colorPresets = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#18181b', '#ffffff'];
const shapePresets: Array<{ label: string; value: IconProps['bgShape'] }> = [
{ label: 'None', value: 'none' },
{ label: 'Circle', value: 'circle' },
{ label: 'Square', value: 'square' },
{ label: 'Rounded', value: 'rounded' },
];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Icon picker */}
<div>
<label style={labelStyle}>Icon</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
{COMMON_ICONS.map((ic) => (
<button
key={ic}
onClick={() => setProp((p: IconProps) => { p.icon = ic; })}
title={ic}
style={{
padding: '6px', fontSize: 16, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.icon === ic ? '#3b82f6' : '#27272a',
color: props.icon === ic ? '#fff' : '#e4e4e7',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<i className={`fa ${ic}`} />
</button>
))}
</div>
</div>
{/* Custom icon class */}
<div>
<label style={labelStyle}>Custom Icon Class</label>
<input
type="text"
value={props.icon || ''}
onChange={(e) => setProp((p: IconProps) => { p.icon = e.target.value; })}
placeholder="fa-star"
style={inputStyle}
/>
</div>
{/* Size */}
<div>
<label style={labelStyle}>Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: IconProps) => { p.size = s; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.size === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Color */}
<div>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: IconProps) => { p.color = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background shape */}
<div>
<label style={labelStyle}>Background Shape</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{shapePresets.map((s) => (
<button
key={s.value}
onClick={() => setProp((p: IconProps) => { p.bgShape = s.value; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.bgShape === s.value ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s.label}
</button>
))}
</div>
</div>
{/* Background color */}
{props.bgShape !== 'none' && (
<div>
<label style={labelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#18181b', '#f1f5f9', '#ffffff'].map((c) => (
<button
key={c}
onClick={() => setProp((p: IconProps) => { p.bgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{/* Background size */}
{props.bgShape !== 'none' && (
<div>
<label style={labelStyle}>Background Size</label>
<input
type="text"
value={props.bgSize || '56px'}
onChange={(e) => setProp((p: IconProps) => { p.bgSize = e.target.value; })}
placeholder="56px"
style={inputStyle}
/>
</div>
)}
{/* Link */}
<div>
<label style={labelStyle}>Link URL</label>
<input
type="text"
value={props.link || ''}
onChange={(e) => setProp((p: IconProps) => { p.link = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Icon.craft = {
@@ -281,9 +102,6 @@ Icon.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: IconSettings,
},
};
/* ---------- HTML export ---------- */
+1 -286
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
@@ -19,25 +19,6 @@ interface LogoProps {
style?: CSSProperties;
}
/* ---------- Image upload helper ---------- */
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; }
}
/* ---------- Component ---------- */
export const Logo: UserComponent<LogoProps> = ({
@@ -92,269 +73,6 @@ export const Logo: UserComponent<LogoProps> = ({
);
};
/* ---------- Settings panel ---------- */
const LogoSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as LogoProps,
}));
const { design } = useSiteDesign();
const logoType = props.type || 'text';
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
const fontFamilies = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
];
const handleLogoUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: LogoProps) => { p.imageSrc = url; });
}, [setProp]);
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]);
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Type toggle */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: LogoProps) => { p.type = 'text'; })}
style={logoType === 'text' ? btnActive : btnSmall}
>
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
</button>
<button
onClick={() => setProp((p: LogoProps) => { p.type = 'image'; })}
style={logoType === 'image' ? btnActive : btnSmall}
>
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
</button>
</div>
</div>
{logoType === 'text' ? (
<>
<div>
<label style={labelStyle}>Logo Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: LogoProps) => { p.text = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Font Family</label>
<select
value={props.fontFamily || 'Inter, sans-serif'}
onChange={(e) => setProp((p: LogoProps) => { p.fontFamily = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{fontFamilies.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Size</label>
<input
type="text"
value={props.fontSize || '20px'}
onChange={(e) => setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
placeholder="20px"
style={inputStyle}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Weight</label>
<select
value={props.fontWeight || '700'}
onChange={(e) => setProp((p: LogoProps) => { p.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>
</div>
<div>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.color || design.textColor}
onChange={(e) => setProp((p: LogoProps) => { p.color = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.color || 'Auto'}</span>
<button
onClick={() => setProp((p: LogoProps) => { p.color = undefined; })}
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
title="Reset to auto"
>Auto</button>
</div>
</div>
</>
) : (
<>
{/* Image logo controls */}
{props.imageSrc ? (
<div style={{ borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.imageSrc} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
<button
onClick={() => setProp((p: LogoProps) => { p.imageSrc = ''; })}
style={{ position: 'absolute', top: 4, right: 4, 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' }}
title="Remove image"
>
<i className="fa fa-times" />
</button>
</div>
) : (
<div
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer' }}
onClick={() => fileInputRef.current?.click()}
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 handleLogoUpload(file);
}}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
Drop logo or click to upload
</div>
)}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, 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: 3 }} /> Browse
</button>
</div>
{/* Browse grid */}
{showBrowser && (
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
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: '8px 0', margin: 0 }}>No images uploaded yet.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
{/* URL paste input */}
<div>
<input
type="text"
value={props.imageSrc || ''}
onChange={(e) => setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
placeholder="Or paste image URL..."
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
/>
</div>
<div>
<label style={labelStyle}>Logo Width</label>
<input
type="text"
value={props.imageWidth || '120px'}
onChange={(e) => setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
placeholder="120px"
style={inputStyle}
/>
</div>
</>
)}
{/* Link URL */}
<div>
<label style={labelStyle}>Link URL</label>
<input
type="text"
value={props.href || '/'}
onChange={(e) => setProp((p: LogoProps) => { p.href = e.target.value; })}
placeholder="/"
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Logo.craft = {
@@ -376,9 +94,6 @@ Logo.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: LogoSettings,
},
};
/* ---------- HTML export ---------- */
-323
View File
@@ -1,7 +1,6 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -100,325 +99,6 @@ export const Menu: UserComponent<MenuProps> = ({
);
};
/* ---------- Settings panel ---------- */
const MenuSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MenuProps,
}));
const { pages } = usePages();
const links = props.links || defaultLinks;
/* Drag state for reordering */
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
/* ---- Link management ---- */
const updateLink = (index: number, field: keyof MenuLink, value: string | boolean) => {
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (link?: Partial<MenuLink>) => {
setProp((p: MenuProps) => {
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
});
};
const removeLink = (index: number) => {
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const moveLink = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
const [moved] = updated.splice(fromIdx, 1);
updated.splice(toIdx, 0, moved);
p.links = updated;
});
};
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sectionStyle: CSSProperties = {
borderBottom: '1px solid #27272a', paddingBottom: 12,
};
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
const gapPresets = ['8px', '16px', '24px', '32px', '40px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ===== Style Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Menu Style</label>
{/* Link color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Link Color</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: MenuProps) => { p.linkColor = c; })}
style={{
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.linkColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
<input
type="color"
value={props.linkColor || '#3f3f46'}
onChange={(e) => setProp((p: MenuProps) => { p.linkColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Hover color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Hover Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.linkHoverColor || '#3b82f6'}
onChange={(e) => setProp((p: MenuProps) => { p.linkHoverColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.linkHoverColor || '#3b82f6'}</span>
</div>
</div>
{/* CTA button colors */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>CTA Button</label>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
<input
type="color"
value={props.ctaBgColor || '#3b82f6'}
onChange={(e) => setProp((p: MenuProps) => { p.ctaBgColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
<input
type="color"
value={props.ctaTextColor || '#ffffff'}
onChange={(e) => setProp((p: MenuProps) => { p.ctaTextColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
</div>
</div>
{/* Font size */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Font Size</label>
<input
type="text"
value={props.fontSize || '14px'}
onChange={(e) => setProp((p: MenuProps) => { p.fontSize = e.target.value; })}
placeholder="14px"
style={inputStyle}
/>
</div>
{/* Alignment */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: MenuProps) => { p.alignment = a; })}
style={(props.alignment || 'right') === a ? btnActive : btnSmall}
>
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
{/* Orientation */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Orientation</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['horizontal', 'vertical'] as const).map((o) => (
<button
key={o}
onClick={() => setProp((p: MenuProps) => { p.orientation = o; })}
style={(props.orientation || 'horizontal') === o ? btnActive : btnSmall}
>
{o.charAt(0).toUpperCase() + o.slice(1)}
</button>
))}
</div>
</div>
{/* Gap */}
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: MenuProps) => { p.gap = g; })}
style={(props.gap || '24px') === g ? btnActive : btnSmall}
>
{g}
</button>
))}
</div>
</div>
</div>
{/* ===== Links Section ===== */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
<div
key={i}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
onDragEnd={() => {
if (dragIdx !== null && dragOverIdx !== null) {
moveLink(dragIdx, dragOverIdx);
}
setDragIdx(null);
setDragOverIdx(null);
}}
style={{
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
borderRadius: 6,
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 4,
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
transition: 'background 0.1s, border-color 0.1s',
}}
>
{/* Row 1: drag handle + text + delete */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
title="Drag to reorder"
>
<i className="fa fa-bars" />
</span>
<input
type="text"
value={link.text}
onChange={(e) => updateLink(i, 'text', e.target.value)}
placeholder="Text"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
title="Delete link"
>
<i className="fa fa-trash" />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={link.href}
onChange={(e) => updateLink(i, 'href', e.target.value)}
placeholder="URL (e.g. /about or https://...)"
style={inputStyle}
/>
{/* Row 3: checkboxes */}
<div style={{ display: 'flex', gap: 8 }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
External
</label>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
CTA
</label>
</div>
</div>
))}
</div>
{/* Add link button */}
<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>
{/* Add page dropdown */}
<select
onChange={(e) => {
const page = pages.find(p => p.id === e.target.value);
if (page) {
addLink({
text: page.name,
href: page.slug === 'index' ? '/' : page.slug,
isExternal: false,
isCta: false,
});
}
e.target.value = '';
}}
value=""
style={{
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
background: '#1e293b', color: '#93c5fd',
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
}}
>
<option value="">+ Add Page...</option>
{pages.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({p.slug === 'index' ? '/' : p.slug})
</option>
))}
</select>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Menu.craft = {
@@ -440,9 +120,6 @@ Menu.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: MenuSettings,
},
};
/* ---------- HTML export ---------- */
+1 -613
View File
@@ -1,7 +1,6 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
@@ -52,25 +51,6 @@ const PADDING_PRESETS = [
{ label: 'Spacious', value: '24px 48px' },
];
/* ---------- Image upload helper (same as ImageBlock) ---------- */
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; }
}
/* ---------- Component ---------- */
export const Navbar: UserComponent<NavbarProps> = ({
@@ -194,595 +174,6 @@ export const Navbar: UserComponent<NavbarProps> = ({
);
};
/* ---------- Settings panel ---------- */
const NavbarSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as NavbarProps,
}));
const { pages } = usePages();
const { design } = useSiteDesign();
const links = props.links || defaultLinks;
const logoType = props.logoType || 'text';
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
/* Drag state for reordering */
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const bgPresets = ['#ffffff', '#f8fafc', '#f9fafb', '#18181b', '#0f172a', '#1e293b', '#1f2937', '#111827'];
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
const fontFamilies = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
];
/* ---- Link management ---- */
const updateLink = (index: number, field: keyof NavLink, value: string | boolean) => {
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (link?: Partial<NavLink>) => {
setProp((p: NavbarProps) => {
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
});
};
const removeLink = (index: number) => {
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const moveLink = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
const [moved] = updated.splice(fromIdx, 1);
updated.splice(toIdx, 0, moved);
p.links = updated;
});
};
/* ---- Image upload for logo ---- */
const handleLogoUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: NavbarProps) => { p.logoImage = url; });
}, [setProp]);
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]);
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sectionStyle: CSSProperties = {
borderBottom: '1px solid #27272a', paddingBottom: 12,
};
const swatchStyle = (color: string, active: boolean): CSSProperties => ({
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: color, cursor: 'pointer',
outline: active ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
});
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ===== Logo Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo</label>
{/* Logo type toggle */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'text'; })}
style={logoType === 'text' ? btnActive : btnSmall}
>
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
</button>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'image'; })}
style={logoType === 'image' ? btnActive : btnSmall}
>
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
</button>
</div>
{logoType === 'text' ? (
<>
{/* Text logo controls */}
<div style={{ marginBottom: 6 }}>
<label style={labelStyle}>Logo Text</label>
<input
type="text"
value={props.logoText || ''}
onChange={(e) => setProp((p: NavbarProps) => { p.logoText = e.target.value; })}
style={inputStyle}
/>
</div>
<div style={{ marginBottom: 6 }}>
<label style={labelStyle}>Font Family</label>
<select
value={props.logoFontFamily || 'Inter, sans-serif'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontFamily = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{fontFamilies.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Size</label>
<input
type="text"
value={props.logoFontSize || '20px'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontSize = e.target.value; })}
placeholder="20px"
style={inputStyle}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<input
type="color"
value={props.logoColor || design.textColor}
onChange={(e) => setProp((p: NavbarProps) => { p.logoColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoColor = undefined; })}
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
title="Reset to auto"
>Auto</button>
</div>
</div>
</div>
</>
) : (
<>
{/* Image logo controls */}
{props.logoImage ? (
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.logoImage} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
<button
onClick={() => setProp((p: NavbarProps) => { p.logoImage = ''; })}
style={{ position: 'absolute', top: 4, right: 4, 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' }}
title="Remove image"
>
<i className="fa fa-times" />
</button>
</div>
) : (
<div
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer', marginBottom: 8 }}
onClick={() => fileInputRef.current?.click()}
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 handleLogoUpload(file);
}}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
Drop logo or click to upload
</div>
)}
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, 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: 3 }} /> Browse
</button>
</div>
{/* Browse grid */}
{showBrowser && (
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginBottom: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: NavbarProps) => { p.logoImage = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
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: '8px 0', margin: 0 }}>No images uploaded yet.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
{/* URL input */}
<div style={{ marginBottom: 6 }}>
<input
type="text"
value={props.logoImage || ''}
onChange={(e) => setProp((p: NavbarProps) => { p.logoImage = e.target.value; })}
placeholder="Or paste image URL..."
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
/>
</div>
<div>
<label style={labelStyle}>Logo Width</label>
<input
type="text"
value={props.logoWidth || '120px'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoWidth = e.target.value; })}
placeholder="120px"
style={inputStyle}
/>
</div>
</>
)}
{/* Logo link URL (shared) */}
<div style={{ marginTop: 6 }}>
<label style={labelStyle}>Logo Link URL</label>
<input
type="text"
value={props.logoUrl || '/'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoUrl = e.target.value; })}
placeholder="/"
style={inputStyle}
/>
</div>
</div>
{/* ===== Nav Style Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Nav Style</label>
{/* Background color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NavbarProps) => { p.backgroundColor = c; })}
style={swatchStyle(c, props.backgroundColor === c)}
/>
))}
<input
type="color"
value={props.backgroundColor || '#ffffff'}
onChange={(e) => setProp((p: NavbarProps) => { p.backgroundColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Text color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Text Color</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NavbarProps) => { p.textColor = c; })}
style={swatchStyle(c, props.textColor === c)}
/>
))}
<input
type="color"
value={props.textColor || '#3f3f46'}
onChange={(e) => setProp((p: NavbarProps) => { p.textColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Link hover color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Hover Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.hoverColor || '#3b82f6'}
onChange={(e) => setProp((p: NavbarProps) => { p.hoverColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.hoverColor || '#3b82f6'}</span>
</div>
</div>
{/* CTA button colors */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>CTA Button</label>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
<input
type="color"
value={props.ctaColor || '#3b82f6'}
onChange={(e) => setProp((p: NavbarProps) => { p.ctaColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
<input
type="color"
value={props.ctaTextColor || '#ffffff'}
onChange={(e) => setProp((p: NavbarProps) => { p.ctaTextColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
</div>
</div>
{/* Padding presets */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{PADDING_PRESETS.map((p) => (
<button
key={p.label}
onClick={() => setProp((pr: NavbarProps) => { pr.padding = p.value; })}
style={props.padding === p.value ? btnActive : btnSmall}
>
{p.label}
</button>
))}
</div>
</div>
{/* Alignment */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right', 'space-between'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: NavbarProps) => { p.navAlignment = a; })}
style={props.navAlignment === a || (!props.navAlignment && a === 'space-between') ? btnActive : btnSmall}
>
{a === 'space-between' ? 'Spread' : a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
{/* Sticky toggle */}
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!props.isSticky}
onChange={(e) => setProp((p: NavbarProps) => { p.isSticky = e.target.checked; })}
/>
Sticky
</label>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!props.showMobileMenu}
onChange={(e) => setProp((p: NavbarProps) => { p.showMobileMenu = e.target.checked; })}
/>
Mobile Menu
</label>
</div>
{/* Design token quick apply */}
<div>
<label style={labelStyle}>Apply Design Token</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: NavbarProps) => {
p.backgroundColor = '#ffffff';
p.textColor = design.textColor;
p.hoverColor = design.primaryColor;
p.ctaColor = design.primaryColor;
p.ctaTextColor = '#ffffff';
})}
style={btnSmall}
>
<i className="fa fa-sun-o" style={{ marginRight: 3 }} />Light
</button>
<button
onClick={() => setProp((p: NavbarProps) => {
p.backgroundColor = '#0f172a';
p.textColor = '#e4e4e7';
p.hoverColor = design.primaryColor;
p.ctaColor = design.primaryColor;
p.ctaTextColor = '#ffffff';
p.logoColor = '#ffffff';
})}
style={btnSmall}
>
<i className="fa fa-moon-o" style={{ marginRight: 3 }} />Dark
</button>
</div>
</div>
</div>
{/* ===== Links Section ===== */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
<div
key={i}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
onDragEnd={() => {
if (dragIdx !== null && dragOverIdx !== null) {
moveLink(dragIdx, dragOverIdx);
}
setDragIdx(null);
setDragOverIdx(null);
}}
style={{
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
borderRadius: 6,
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 4,
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
transition: 'background 0.1s, border-color 0.1s',
}}
>
{/* Row 1: drag handle + text + delete */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
title="Drag to reorder"
>
<i className="fa fa-bars" />
</span>
<input
type="text"
value={link.text}
onChange={(e) => updateLink(i, 'text', e.target.value)}
placeholder="Text"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
title="Delete link"
>
<i className="fa fa-trash" />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={link.href}
onChange={(e) => updateLink(i, 'href', e.target.value)}
placeholder="URL (e.g. /about or https://...)"
style={inputStyle}
/>
{/* Row 3: checkboxes */}
<div style={{ display: 'flex', gap: 8 }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
External
</label>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
CTA
</label>
</div>
</div>
))}
</div>
{/* Add link button */}
<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>
{/* Add page dropdown */}
<select
onChange={(e) => {
const page = pages.find(p => p.id === e.target.value);
if (page) {
addLink({
text: page.name,
href: page.slug === 'index' ? '/' : page.slug,
isExternal: false,
isCta: false,
});
}
e.target.value = '';
}}
value=""
style={{
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
background: '#1e293b', color: '#93c5fd',
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
}}
>
<option value="">+ Add Page...</option>
{pages.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({p.slug === 'index' ? '/' : p.slug})
</option>
))}
</select>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Navbar.craft = {
@@ -815,9 +206,6 @@ Navbar.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: NavbarSettings,
},
};
/* ---------- HTML export ---------- */
-59
View File
@@ -93,62 +93,6 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
);
};
/* ---------- Settings panel ---------- */
const SearchBarSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SearchBarProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Placeholder */}
<div>
<label style={labelStyle}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: SearchBarProps) => { p.placeholder = e.target.value; })}
placeholder="Search..."
style={inputStyle}
/>
</div>
{/* Show Button */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showButton !== false}
onChange={(e) => setProp((p: SearchBarProps) => { p.showButton = e.target.checked; })}
/>
Show Button
</label>
</div>
{/* Button Text */}
{props.showButton !== false && (
<div>
<label style={labelStyle}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: SearchBarProps) => { p.buttonText = e.target.value; })}
placeholder="Search"
style={inputStyle}
/>
</div>
)}
</div>
);
};
/* ---------- Craft config ---------- */
SearchBar.craft = {
@@ -164,9 +108,6 @@ SearchBar.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SearchBarSettings,
},
};
/* ---------- HTML export ---------- */
-234
View File
@@ -49,8 +49,6 @@ const platformLabels: Record<string, string> = {
twitch: 'Twitch',
};
const allPlatforms = Object.keys(platformIcons);
const defaultLinks: SocialLink[] = [
{ platform: 'facebook', url: '#' },
{ platform: 'twitter', url: '#' },
@@ -140,235 +138,6 @@ export const SocialLinks: UserComponent<SocialLinksProps> = ({
);
};
/* ---------- Settings panel ---------- */
const SocialLinksSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SocialLinksProps,
}));
const links = props.links || defaultLinks;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateLink = (index: number, field: keyof SocialLink, value: string) => {
setProp((p: SocialLinksProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (platform: string) => {
setProp((p: SocialLinksProps) => {
p.links = [...(p.links || defaultLinks), { platform, url: '#' }];
});
};
const removeLink = (index: number) => {
setProp((p: SocialLinksProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const usedPlatforms = new Set(links.map((l) => l.platform));
const availablePlatforms = allPlatforms.filter((p) => !usedPlatforms.has(p));
const sizePresets = ['14px', '18px', '20px', '24px', '28px', '32px'];
const gapPresets = ['4px', '8px', '10px', '14px', '20px'];
const iconColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#a1a1aa'];
const bgColorPresets = ['#374151', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#0ea5e9', 'transparent'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Links Editor */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Social Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{links.map((link, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 14, width: 20, textAlign: 'center', flex: 'none' }}>
<i className={`fa ${platformIcons[link.platform] || 'fa-link'}`} style={{ color: '#a1a1aa' }} />
</span>
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1 }}>
{platformLabels[link.platform] || link.platform}
</span>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<input
type="text"
value={link.url}
onChange={(e) => updateLink(i, 'url', e.target.value)}
placeholder="https://..."
style={inputStyle}
/>
</div>
))}
</div>
{availablePlatforms.length > 0 && (
<div style={{ marginTop: 6 }}>
<select
onChange={(e) => {
if (e.target.value) {
addLink(e.target.value);
e.target.value = '';
}
}}
defaultValue=""
style={{ ...inputStyle, width: '100%', padding: '6px', cursor: 'pointer' }}
>
<option value="">+ Add Platform...</option>
{availablePlatforms.map((p) => (
<option key={p} value={p}>{platformLabels[p]}</option>
))}
</select>
</div>
)}
</div>
{/* Alignment */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: SocialLinksProps) => { p.alignment = a; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.alignment === a ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{a}
</button>
))}
</div>
</div>
{/* Icon Shape */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Shape</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(['none', 'circle', 'square', 'rounded'] as const).map((s) => (
<button
key={s}
onClick={() => setProp((p: SocialLinksProps) => { p.iconShape = s; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.iconShape === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Icon Size */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: SocialLinksProps) => { p.iconSize = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.iconSize === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Icon Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{iconColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SocialLinksProps) => { p.iconColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.iconColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Icon Background Color */}
{props.iconShape !== 'none' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SocialLinksProps) => { p.iconBgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4,
border: c === 'transparent' ? '2px dashed #3f3f46' : '1px solid #3f3f46',
backgroundColor: c === 'transparent' ? undefined : c,
cursor: 'pointer',
outline: props.iconBgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{/* Gap */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: SocialLinksProps) => { p.gap = g; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
SocialLinks.craft = {
@@ -388,9 +157,6 @@ SocialLinks.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SocialLinksSettings,
},
};
/* ---------- HTML export ---------- */
-48
View File
@@ -33,51 +33,6 @@ export const Spacer: UserComponent<SpacerProps> = ({
);
};
/* ---------- Settings panel ---------- */
const SpacerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SpacerProps,
}));
const heightPresets = ['20px', '40px', '60px', '80px', '120px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: SpacerProps) => { p.height = h; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Custom Height</label>
<input
type="text"
value={props.height || ''}
onChange={(e) => setProp((p: SpacerProps) => { p.height = e.target.value; })}
placeholder="e.g. 50px, 5rem"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Spacer.craft = {
@@ -91,9 +46,6 @@ Spacer.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SpacerSettings,
},
};
/* ---------- HTML export ---------- */
-104
View File
@@ -75,107 +75,6 @@ export const StarRating: UserComponent<StarRatingProps> = ({
);
};
/* ---------- Settings panel ---------- */
const StarRatingSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as StarRatingProps,
}));
const sizePresets = ['16px', '20px', '24px', '32px', '40px'];
const filledColorPresets = ['#f59e0b', '#eab308', '#f97316', '#ef4444', '#ec4899', '#3b82f6', '#10b981', '#18181b'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Rating: {props.rating ?? 4.5}
</label>
<input
type="range"
min={0}
max={props.maxStars || 5}
step={0.5}
value={props.rating ?? 4.5}
onChange={(e) => setProp((p: StarRatingProps) => { p.rating = parseFloat(e.target.value); })}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Max Stars</label>
<div style={{ display: 'flex', gap: 4 }}>
{[3, 4, 5, 6, 7, 10].map((n) => (
<button
key={n}
onClick={() => setProp((p: StarRatingProps) => {
p.maxStars = n;
if ((p.rating || 0) > n) p.rating = n;
})}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.maxStars === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Star Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: StarRatingProps) => { p.size = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.size === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Filled Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{filledColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: StarRatingProps) => { p.filledColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.filledColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Empty Color</label>
<input
type="color"
value={props.emptyColor || '#d1d5db'}
onChange={(e) => setProp((p: StarRatingProps) => { p.emptyColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
StarRating.craft = {
@@ -193,9 +92,6 @@ StarRating.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: StarRatingSettings,
},
};
/* ---------- HTML export ---------- */
-58
View File
@@ -1,9 +1,6 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { TypographyControl } from '../../ui/TypographyControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
interface TextBlockProps {
text?: string;
@@ -75,58 +72,6 @@ export const TextBlock: UserComponent<TextBlockProps> = ({
);
};
/* ---------- Settings panel ---------- */
const TextBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TextBlockProps,
}));
return (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text Content</label>
<textarea
value={props.text || ''}
onChange={(e) => setProp((p: TextBlockProps) => { p.text = e.target.value; })}
rows={4}
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
/>
</div>
</div>
}
style={
<TypographyControl
style={props.style || {}}
onChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
/>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
cssId={props.cssId || ''}
onCssIdChange={(id) => setProp((p: TextBlockProps) => { p.cssId = id; })}
cssClass={props.cssClass || ''}
onCssClassChange={(cls) => setProp((p: TextBlockProps) => { p.cssClass = cls; })}
hideOnDesktop={props.hideOnDesktop}
onHideOnDesktopChange={(v) => setProp((p: TextBlockProps) => { p.hideOnDesktop = v; })}
hideOnTablet={props.hideOnTablet}
onHideOnTabletChange={(v) => setProp((p: TextBlockProps) => { p.hideOnTablet = v; })}
hideOnMobile={props.hideOnMobile}
onHideOnMobileChange={(v) => setProp((p: TextBlockProps) => { p.hideOnMobile = v; })}
animation={props.animation}
onAnimationChange={(v) => setProp((p: TextBlockProps) => { p.animation = v; })}
animationDelay={props.animationDelay}
onAnimationDelayChange={(v) => setProp((p: TextBlockProps) => { p.animationDelay = v; })}
/>
}
/>
);
};
/* ---------- Craft config ---------- */
TextBlock.craft = {
@@ -144,9 +89,6 @@ TextBlock.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TextBlockSettings,
},
};
/* ---------- HTML export ---------- */
-227
View File
@@ -137,230 +137,6 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
);
};
/* ---------- Settings panel ---------- */
const fieldTypes: ContactFormField['type'][] = ['text', 'email', 'tel', 'textarea', 'select'];
const ContactFormSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContactFormProps,
}));
const fields = props.fields || defaultFields;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateField = (index: number, key: keyof ContactFormField, value: any) => {
setProp((p: ContactFormProps) => {
const updated = [...(p.fields || defaultFields)];
updated[index] = { ...updated[index], [key]: value };
p.fields = updated;
});
};
const addField = () => {
setProp((p: ContactFormProps) => {
p.fields = [...(p.fields || defaultFields), { type: 'text', label: 'New Field', name: 'new_field', placeholder: '', required: false }];
});
};
const removeField = (index: number) => {
setProp((p: ContactFormProps) => {
const updated = [...(p.fields || defaultFields)];
updated.splice(index, 1);
p.fields = updated;
});
};
const submitColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Form Action */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
<input
type="text"
value={props.formAction || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.formAction = e.target.value; })}
placeholder="https://... or /api/submit"
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
{/* Relay recipient */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Send submissions to (email)</label>
<input type="email" value={props.recipientEmail || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.recipientEmail = e.target.value; })}
placeholder="you@example.com" style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Delivered via the site's contact-form relay. Requires the relay to be enabled on this server.
</p>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thank-you page URL (optional)</label>
<input type="text" value={props.thankYouUrl || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.thankYouUrl = e.target.value; })}
placeholder="/thank-you (blank = hosted page)" style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }} />
</div>
{/* Success Message */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
<input
type="text"
value={props.successMessage || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.successMessage = e.target.value; })}
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
{/* Submit Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Text</label>
<input
type="text"
value={props.submitText || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.submitText = e.target.value; })}
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{submitColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: ContactFormProps) => { p.submitColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.submitColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label Color</label>
<input
type="color"
value={props.labelColor || '#374151'}
onChange={(e) => setProp((p: ContactFormProps) => { p.labelColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Input Background */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Background</label>
<input
type="color"
value={props.inputBg || '#ffffff'}
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBg = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Input Border */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Border Color</label>
<input
type="color"
value={props.inputBorder || '#d1d5db'}
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBorder = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Fields Editor */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Fields</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map((field, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select
value={field.type}
onChange={(e) => updateField(i, 'type', e.target.value)}
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
>
{fieldTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<input
type="text"
value={field.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
placeholder="Label"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeField(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={field.name}
onChange={(e) => updateField(i, 'name', e.target.value)}
placeholder="name attr"
style={{ ...inputStyle, flex: 1 }}
/>
<input
type="text"
value={field.placeholder}
onChange={(e) => updateField(i, 'placeholder', e.target.value)}
placeholder="Placeholder"
style={{ ...inputStyle, flex: 1 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(i, 'required', e.target.checked)}
/>
Required
</label>
</div>
{field.type === 'select' && (
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Options (one per line)</label>
<textarea
value={(field.options || []).join('\n')}
onChange={(e) => updateField(i, 'options', e.target.value.split('\n').filter((s: string) => s.trim()))}
rows={3}
placeholder="Option 1&#10;Option 2&#10;Option 3"
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
)}
</div>
))}
</div>
<button
onClick={addField}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Field
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ContactForm.craft = {
@@ -387,9 +163,6 @@ ContactForm.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ContactFormSettings,
},
};
/* ---------- HTML export ---------- */
-98
View File
@@ -42,101 +42,6 @@ export const FormButton: UserComponent<FormButtonProps> = ({
);
};
/* ---------- Settings panel ---------- */
const FormButtonSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FormButtonProps,
}));
const colorPresets = [
{ bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
{ bg: '#10b981', color: '#ffffff', label: 'Green' },
{ bg: '#ef4444', color: '#ffffff', label: 'Red' },
{ bg: '#f59e0b', color: '#18181b', label: 'Amber' },
{ bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
{ bg: '#18181b', color: '#ffffff', label: 'Dark' },
];
const radiusPresets = ['0px', '4px', '6px', '8px', '9999px'];
const widthPresets = ['auto', '100%'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: FormButtonProps) => { p.text = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((preset) => (
<button
key={preset.label}
onClick={() => setProp((p: FormButtonProps) => {
p.style = { ...p.style, backgroundColor: preset.bg, color: preset.color };
})}
title={preset.label}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: preset.bg, cursor: 'pointer',
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{radiusPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, borderRadius: r }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Width</label>
<div style={{ display: 'flex', gap: 4 }}>
{widthPresets.map((w) => (
<button
key={w}
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, width: w }; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.width === w ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{w === 'auto' ? 'Auto' : 'Full Width'}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
FormButton.craft = {
@@ -158,9 +63,6 @@ FormButton.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FormButtonSettings,
},
};
/* ---------- HTML export ---------- */
@@ -43,94 +43,6 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
);
};
/* ---------- Settings panel ---------- */
const FormContainerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FormContainerProps,
}));
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Send submissions to (email)</label>
<input
type="email"
value={props.recipientEmail || ''}
onChange={(e) => setProp((p: FormContainerProps) => { p.recipientEmail = e.target.value; })}
placeholder="you@example.com"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Delivered via the site's contact-form relay. Requires the relay to be enabled on this server. Leave blank to use the Form Action URL below instead.
</p>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thank-you page URL (optional)</label>
<input
type="text"
value={props.thankYouUrl || ''}
onChange={(e) => setProp((p: FormContainerProps) => { p.thankYouUrl = e.target.value; })}
placeholder="/thank-you (blank = hosted page)"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
<input
type="text"
value={props.action || ''}
onChange={(e) => setProp((p: FormContainerProps) => { p.action = e.target.value; })}
placeholder="https://... or /api/submit"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Method</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['GET', 'POST'] as const).map((m) => (
<button
key={m}
onClick={() => setProp((p: FormContainerProps) => { p.method = m; })}
style={{
padding: '4px 12px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.method === m ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{m}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FormContainerProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
FormContainer.craft = {
@@ -152,9 +64,6 @@ FormContainer.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FormContainerSettings,
},
};
/* ---------- HTML export ---------- */
-78
View File
@@ -66,81 +66,6 @@ export const InputField: UserComponent<InputFieldProps> = ({
);
};
/* ---------- Settings panel ---------- */
const InputFieldSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as InputFieldProps,
}));
const typeOptions: InputFieldProps['type'][] = ['text', 'email', 'password', 'number', 'tel', 'url'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
<input
type="text"
value={props.label || ''}
onChange={(e) => setProp((p: InputFieldProps) => { p.label = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Type</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{typeOptions.map((t) => (
<button
key={t}
onClick={() => setProp((p: InputFieldProps) => { p.type = t; })}
style={{
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.type === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
<input
type="text"
value={props.name || ''}
onChange={(e) => setProp((p: InputFieldProps) => { p.name = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: InputFieldProps) => { p.placeholder = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={!!props.required}
onChange={(e) => setProp((p: InputFieldProps) => { p.required = e.target.checked; })}
/>
Required
</label>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
InputField.craft = {
@@ -158,9 +83,6 @@ InputField.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: InputFieldSettings,
},
};
/* ---------- HTML export ---------- */
@@ -99,132 +99,6 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
);
};
/* ---------- Settings panel ---------- */
const SubscribeFormSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SubscribeFormProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const buttonColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a', '#eff6ff', '#f0fdf4', '#fef3c7'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Heading */}
<div>
<label style={labelStyle}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.heading = e.target.value; })}
placeholder="Subscribe to our newsletter"
style={inputStyle}
/>
</div>
{/* Placeholder */}
<div>
<label style={labelStyle}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.placeholder = e.target.value; })}
placeholder="Enter your email"
style={inputStyle}
/>
</div>
{/* Button Text */}
<div>
<label style={labelStyle}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.buttonText = e.target.value; })}
placeholder="Subscribe"
style={inputStyle}
/>
</div>
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'inline'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.layout || 'inline') === 'inline' ? '#3b82f6' : '#27272a',
color: (props.layout || 'inline') === 'inline' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Inline
</button>
<button
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'stacked'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'stacked' ? '#3b82f6' : '#27272a',
color: props.layout === 'stacked' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Stacked
</button>
</div>
</div>
{/* Button Color */}
<div>
<label style={labelStyle}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{buttonColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SubscribeFormProps) => { p.buttonColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.buttonColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SubscribeFormProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
SubscribeForm.craft = {
@@ -242,9 +116,6 @@ SubscribeForm.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SubscribeFormSettings,
},
};
/* ---------- HTML export ---------- */
@@ -68,81 +68,6 @@ export const TextareaField: UserComponent<TextareaFieldProps> = ({
);
};
/* ---------- Settings panel ---------- */
const TextareaFieldSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TextareaFieldProps,
}));
const rowsPresets = [2, 3, 4, 6, 8];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
<input
type="text"
value={props.label || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.label = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
<input
type="text"
value={props.name || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.name = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.placeholder = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Rows</label>
<div style={{ display: 'flex', gap: 4 }}>
{rowsPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: TextareaFieldProps) => { p.rows = r; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.rows === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={!!props.required}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.required = e.target.checked; })}
/>
Required
</label>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
TextareaField.craft = {
@@ -160,9 +85,6 @@ TextareaField.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TextareaFieldSettings,
},
};
/* ---------- HTML export ---------- */
@@ -2,7 +2,6 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeAttr } from '../../utils/escape';
interface BackgroundSectionProps {
@@ -70,93 +69,6 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
);
};
/* ---------- Settings panel ---------- */
const BackgroundSectionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as BackgroundSectionProps,
}));
const bgColorPresets = ['#1e293b', '#0f172a', '#18181b', '#1e3a5f', '#312e81', '#064e3b', '#7f1d1d', '#ffffff'];
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
<input
type="text"
value={props.bgImage || ''}
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.bgImage = e.target.value; })}
placeholder="https://... or /storage/assets/..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: BackgroundSectionProps) => { p.bgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{overlayPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: BackgroundSectionProps) => { p.overlayColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Overlay Opacity: {Math.round((props.overlayOpacity ?? 0.4) * 100)}%
</label>
<input
type="range"
min={0}
max={100}
value={Math.round((props.overlayOpacity ?? 0.4) * 100)}
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.overlayOpacity = parseInt(e.target.value, 10) / 100; })}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.innerMaxWidth = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
BackgroundSection.craft = {
@@ -175,9 +87,6 @@ BackgroundSection.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: BackgroundSectionSettings,
},
};
/* ---------- HTML export ---------- */
+1 -177
View File
@@ -1,8 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeAttr } from '../../utils/escape';
type SplitOption =
@@ -95,178 +94,6 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
);
};
/* ---------- Settings panel ---------- */
const ColumnLayoutSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ColumnLayoutProps,
}));
const [showCustom, setShowCustom] = useState(false);
/* Preset options -- common splits up to 6 columns */
const presetOptions: { columns: number; split: SplitOption; label: string }[] = [
{ columns: 1, split: '100', label: '1 Col' },
{ columns: 2, split: '50-50', label: '2 Equal' },
{ columns: 2, split: '30-70', label: '30/70' },
{ columns: 2, split: '70-30', label: '70/30' },
{ columns: 2, split: '40-60', label: '40/60' },
{ columns: 2, split: '60-40', label: '60/40' },
{ columns: 3, split: '33-33-33', label: '3 Equal' },
{ columns: 3, split: '25-50-25', label: '25/50/25' },
{ columns: 4, split: '25-25-25-25', label: '4 Equal' },
{ columns: 5, split: '20-20-20-20-20', label: '5 Equal' },
{ columns: 6, split: '16-16-16-16-16-16', label: '6 Equal' },
];
const gapPresets = ['0px', '8px', '16px', '24px', '32px'];
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
{/* Preset layouts */}
<div>
<label style={labelStyle}>Column Layout</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{presetOptions.map((opt) => (
<button
key={opt.label}
onClick={() => {
setProp((p: ColumnLayoutProps) => { p.columns = opt.columns; p.split = opt.split; });
setShowCustom(false);
}}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.split === opt.split && props.columns === opt.columns ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Custom column count (7-10) */}
<div>
<button
onClick={() => setShowCustom(!showCustom)}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: showCustom ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
width: '100%',
}}
>
{showCustom ? 'Hide Custom' : 'Custom (7-10 columns)'}
</button>
{showCustom && (
<div style={{ marginTop: 8 }}>
<label style={labelStyle}>Number of Columns</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="range"
min={1}
max={10}
value={props.columns || 2}
onChange={(e) => {
const cols = parseInt(e.target.value);
setProp((p: ColumnLayoutProps) => { p.columns = cols; p.split = 'equal'; });
}}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 12, color: '#e4e4e7', minWidth: 24, textAlign: 'center' }}>{props.columns || 2}</span>
</div>
</div>
)}
</div>
{/* Gap */}
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: ColumnLayoutProps) => { p.gap = g; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
{/* Individual Column Widths */}
<div>
<label style={labelStyle}>Column Widths (%)</label>
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 6 }}>
Adjust each column's width. Values should roughly total 100%.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Array.from({ length: props.columns || 2 }).map((_, i) => {
const currentWidths = getWidths(props.split || 'equal', props.columns || 2);
const currentPct = parseFloat(currentWidths[i]) || (100 / (props.columns || 2));
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 10, color: '#71717a', minWidth: 40 }}>Col {i + 1}</span>
<input
type="range"
min={10}
max={90}
step={5}
value={Math.round(currentPct)}
onChange={(e) => {
const newPct = parseInt(e.target.value);
const cols = props.columns || 2;
const widths = getWidths(props.split || 'equal', cols).map(w => parseFloat(w));
const oldPct = widths[i];
const diff = newPct - oldPct;
widths[i] = newPct;
// Distribute the difference across other columns proportionally
const others = widths.filter((_, j) => j !== i);
const otherTotal = others.reduce((a, b) => a + b, 0);
if (otherTotal > 0) {
for (let j = 0; j < widths.length; j++) {
if (j !== i) {
widths[j] = widths[j] - (diff * (widths[j] / otherTotal));
if (widths[j] < 5) widths[j] = 5;
}
}
}
// Normalize to 100%
const total = widths.reduce((a, b) => a + b, 0);
const normalized = widths.map(w => ((w / total) * 100).toFixed(1) + '%');
const customSplit = normalized.map(w => parseFloat(w).toFixed(0)).join('-') as SplitOption;
setProp((p: ColumnLayoutProps) => { p.split = customSplit; });
}}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 11, color: '#e4e4e7', minWidth: 35, textAlign: 'right' }}>
{Math.round(currentPct)}%
</span>
</div>
);
})}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ColumnLayout.craft = {
@@ -283,9 +110,6 @@ ColumnLayout.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ColumnLayoutSettings,
},
};
/* ---------- HTML export ---------- */
-238
View File
@@ -1,10 +1,6 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { BorderControl } from '../../ui/BorderControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeAttr } from '../../utils/escape';
interface ContainerProps {
@@ -70,237 +66,6 @@ export const Container: UserComponent<ContainerProps> = ({
return el;
};
/* ---------- Settings panel ---------- */
const cLabelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const cInputStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const cPresetBtnStyle = (active: boolean): React.CSSProperties => ({
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46', background: active ? '#3b82f6' : '#27272a', color: active ? '#fff' : '#e4e4e7',
});
const cSwatchStyle = (color: string, active: boolean): React.CSSProperties => ({
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46', backgroundColor: color, cursor: 'pointer',
outline: active ? '2px solid #3b82f6' : 'none', outlineOffset: 1,
});
const cToggleBtnStyle = (active: boolean): React.CSSProperties => ({
flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#e4e4e7',
fontWeight: active ? 600 : 400,
textAlign: 'center',
});
const ContainerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContainerProps,
}));
const bgColors = ['transparent', '#ffffff', '#f9fafb', '#f1f5f9', '#1f2937', '#111827', '#0f172a', '#3b82f6', '#10b981', '#8b5cf6', '#ec4899', '#f59e0b'];
const gradients = [
{ label: 'None', value: 'none' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' },
{ label: 'Green', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
];
const alignPresets = [
{ label: 'Left', value: 'left', icon: 'fa-align-left' },
{ label: 'Center', value: 'center', icon: 'fa-align-center' },
{ label: 'Right', value: 'right', icon: 'fa-align-right' },
];
const currentBg = props.style?.backgroundColor || '';
const currentBgImage = props.style?.backgroundImage || '';
return (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<AnchorIdField />
{/* Tag */}
<div>
<label style={cLabelStyle}>HTML Element</label>
<select
value={props.tag || 'div'}
onChange={(e) => setProp((p: ContainerProps) => { p.tag = e.target.value as ContainerProps['tag']; })}
style={cInputStyle}
>
{['div', 'section', 'article', 'header', 'footer', 'main'].map((t) => (
<option key={t} value={t}>&lt;{t}&gt;</option>
))}
</select>
</div>
{/* Full Width */}
<div>
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500, cursor: 'pointer' }}>
<input
type="checkbox"
checked={props.fullWidth || false}
onChange={(e) => setProp((p: ContainerProps) => { p.fullWidth = e.target.checked; })}
/>
Full Width
</label>
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 2 }}>
Breaks out of parent constraints to fill the viewport width
</span>
</div>
{/* Content Width */}
<div>
<label style={cLabelStyle}>Content Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'full'; })}
style={cToggleBtnStyle((props.contentWidth || 'full') === 'full')}
>
Full
</button>
<button
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'boxed'; })}
style={cToggleBtnStyle(props.contentWidth === 'boxed')}
>
Boxed (1200px)
</button>
</div>
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 4 }}>
{props.contentWidth === 'boxed'
? 'Content is centered with a max-width of 1200px'
: 'Content fills the full container width'}
</span>
</div>
</div>
}
style={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Background Color */}
<div>
<label style={cLabelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColors.map((c) => (
<button key={c} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundColor: c, backgroundImage: 'none' }; })}
style={cSwatchStyle(c === 'transparent' ? '#fff' : c, currentBg === c)} title={c} />
))}
</div>
</div>
{/* Background Gradient */}
<div>
<label style={cLabelStyle}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradients.map((g) => (
<button key={g.value} onClick={() => setProp((p: ContainerProps) => {
p.style = { ...p.style, backgroundImage: g.value === 'none' ? 'none' : g.value, backgroundColor: 'transparent' };
})} style={{
width: 32, height: 24, borderRadius: 4, cursor: 'pointer',
border: currentBgImage === g.value ? '2px solid #3b82f6' : '1px solid #3f3f46',
background: g.value === 'none' ? '#27272a' : g.value,
}} title={g.label} />
))}
</div>
</div>
{/* Background Image */}
<div>
<label style={cLabelStyle}>Background Image</label>
<input type="text" placeholder="Image URL..."
value={(props.style?.backgroundImage || '').replace(/^url\(['"]?|['"]?\)$/g, '')}
onChange={(e) => {
const val = e.target.value.trim();
setProp((p: ContainerProps) => {
p.style = { ...p.style, backgroundImage: val ? `url('${val}')` : 'none', backgroundSize: 'cover', backgroundPosition: 'center' };
});
}}
style={cInputStyle} />
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{['cover', 'contain', 'auto'].map((s) => (
<button key={s} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundSize: s }; })}
style={cPresetBtnStyle(props.style?.backgroundSize === s)}>{s}</button>
))}
{['center', 'top', 'bottom'].map((pos) => (
<button key={pos} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundPosition: pos }; })}
style={cPresetBtnStyle(props.style?.backgroundPosition === pos)}>{pos}</button>
))}
</div>
</div>
{/* Overlay */}
<div>
<label style={cLabelStyle}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={props.style?.['--overlayColor' as keyof CSSProperties] || '#000000'}
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, ['--overlayColor' as keyof CSSProperties]: e.target.value }; })}
style={{ width: 32, height: 24, border: 'none', background: 'none', cursor: 'pointer' }} />
<span style={{ fontSize: 11, color: '#71717a' }}>Overlay (via CSS custom property)</span>
</div>
</div>
{/* Parallax */}
<div>
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500 }}>
<input type="checkbox"
checked={props.style?.backgroundAttachment === 'fixed'}
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundAttachment: e.target.checked ? 'fixed' : 'scroll' }; })} />
Parallax Effect
</label>
</div>
{/* Text Alignment */}
<div>
<label style={cLabelStyle}>Content Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{alignPresets.map((a) => (
<button key={a.value} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, textAlign: a.value as any }; })}
style={{ ...cPresetBtnStyle(props.style?.textAlign === a.value), flex: 1 }}>
<i className={`fa ${a.icon}`} />
</button>
))}
</div>
</div>
{/* Border */}
<BorderControl
style={props.style || {}}
onChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
/>
</div>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
showTagSelector
tag={props.tag || 'div'}
onTagChange={(tag) => setProp((p: ContainerProps) => { p.tag = tag as ContainerProps['tag']; })}
cssId={props.cssId || ''}
onCssIdChange={(id) => setProp((p: ContainerProps) => { p.cssId = id; })}
cssClass={props.cssClass || ''}
onCssClassChange={(cls) => setProp((p: ContainerProps) => { p.cssClass = cls; })}
hideOnDesktop={props.hideOnDesktop}
onHideOnDesktopChange={(v) => setProp((p: ContainerProps) => { p.hideOnDesktop = v; })}
hideOnTablet={props.hideOnTablet}
onHideOnTabletChange={(v) => setProp((p: ContainerProps) => { p.hideOnTablet = v; })}
hideOnMobile={props.hideOnMobile}
onHideOnMobileChange={(v) => setProp((p: ContainerProps) => { p.hideOnMobile = v; })}
animation={props.animation}
onAnimationChange={(v) => setProp((p: ContainerProps) => { p.animation = v; })}
animationDelay={props.animationDelay}
onAnimationDelayChange={(v) => setProp((p: ContainerProps) => { p.animationDelay = v; })}
/>
}
/>
);
};
/* ---------- Craft config ---------- */
Container.craft = {
@@ -317,9 +82,6 @@ Container.craft = {
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: ContainerSettings,
},
};
/* ---------- HTML export ---------- */
-198
View File
@@ -2,7 +2,6 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { Container } from './Container';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeAttr } from '../../utils/escape';
/* ---------- Shape Divider SVG Paths ---------- */
@@ -17,8 +16,6 @@ const DIVIDER_PATHS: Record<Exclude<DividerShape, 'none'>, string> = {
zigzag: 'M0,120 L100,40 L200,120 L300,40 L400,120 L500,40 L600,120 L700,40 L800,120 L900,40 L1000,120 L1100,40 L1200,120 Z',
};
const DIVIDER_SHAPES: DividerShape[] = ['none', 'wave', 'angle', 'curve', 'triangle', 'zigzag'];
interface SectionProps {
style?: CSSProperties;
innerMaxWidth?: string;
@@ -134,198 +131,6 @@ export const Section: UserComponent<SectionProps> = ({
);
};
/* ---------- Settings panel ---------- */
const sLabelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const sInputStyle: React.CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sSelectStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const DividerSettings: React.FC<{
label: string;
shape: DividerShape;
color: string;
height: string;
onShapeChange: (s: DividerShape) => void;
onColorChange: (c: string) => void;
onHeightChange: (h: string) => void;
}> = ({ label, shape, color, height, onShapeChange, onColorChange, onHeightChange }) => {
const heightNum = parseInt(height, 10) || 50;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={sLabelStyle}>{label}</label>
{/* Shape selector */}
<select
value={shape || 'none'}
onChange={(e) => onShapeChange(e.target.value as DividerShape)}
style={sSelectStyle}
>
{DIVIDER_SHAPES.map((s) => (
<option key={s} value={s}>{s === 'none' ? 'None' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
{shape && shape !== 'none' && (
<>
{/* Color picker */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="color"
value={color || '#ffffff'}
onChange={(e) => onColorChange(e.target.value)}
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
/>
<input
type="text"
value={color || '#ffffff'}
onChange={(e) => onColorChange(e.target.value)}
style={{ ...sInputStyle, flex: 1 }}
placeholder="#ffffff"
/>
</div>
{/* Height slider */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 10, color: '#71717a' }}>Height</span>
<span style={{ fontSize: 10, color: '#a1a1aa' }}>{heightNum}px</span>
</div>
<input
type="range"
min={10}
max={200}
value={heightNum}
onChange={(e) => onHeightChange(`${e.target.value}px`)}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
{/* Small SVG preview */}
<div style={{ background: '#18181b', borderRadius: 4, padding: 4, border: '1px solid #3f3f46', overflow: 'hidden' }}>
<svg viewBox="0 0 1200 120" preserveAspectRatio="none" style={{ width: '100%', height: 30, fill: color || '#ffffff', display: 'block' }}>
<path d={DIVIDER_PATHS[shape]} />
</svg>
</div>
</>
)}
</div>
);
};
const SectionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SectionProps,
}));
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#0f172a', '#1e293b', '#18181b', '#f0fdf4', '#eff6ff'];
const paddingPresets = ['0px', '20px', '40px', '60px', '80px', '120px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SectionProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Gradient</label>
<input
type="text"
placeholder="e.g. linear-gradient(135deg, #667eea, #764ba2)"
value={(props.style?.background as string) || ''}
onChange={(e) => setProp((p: SectionProps) => { p.style = { ...p.style, background: e.target.value }; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding (top/bottom)</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{paddingPresets.map((p) => (
<button
key={p}
onClick={() => setProp((pr: SectionProps) => {
pr.style = { ...pr.style, paddingTop: p, paddingBottom: p };
})}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.paddingTop === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => setProp((p: SectionProps) => { p.innerMaxWidth = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
{/* Divider separator */}
<div style={{ borderTop: '1px solid #3f3f46', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7', marginBottom: 10 }}>Shape Dividers</div>
<DividerSettings
label="Top Divider"
shape={props.topDivider || 'none'}
color={props.topDividerColor || '#ffffff'}
height={props.topDividerHeight || '50px'}
onShapeChange={(s) => setProp((p: SectionProps) => { p.topDivider = s; })}
onColorChange={(c) => setProp((p: SectionProps) => { p.topDividerColor = c; })}
onHeightChange={(h) => setProp((p: SectionProps) => { p.topDividerHeight = h; })}
/>
<div style={{ height: 10 }} />
<DividerSettings
label="Bottom Divider"
shape={props.bottomDivider || 'none'}
color={props.bottomDividerColor || '#ffffff'}
height={props.bottomDividerHeight || '50px'}
onShapeChange={(s) => setProp((p: SectionProps) => { p.bottomDivider = s; })}
onColorChange={(c) => setProp((p: SectionProps) => { p.bottomDividerColor = c; })}
onHeightChange={(h) => setProp((p: SectionProps) => { p.bottomDividerHeight = h; })}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Section.craft = {
@@ -346,9 +151,6 @@ Section.craft = {
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: SectionSettings,
},
};
/* ---------- HTML export ---------- */
+1 -383
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import React, { CSSProperties, useCallback, useRef } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
@@ -81,392 +81,10 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
);
};
/* ---------- Helpers for parsing CSS unit values ---------- */
type SizeUnit = 'px' | '%' | 'auto';
function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } {
if (!value || value === 'auto') return { num: '', unit: 'auto' };
const str = String(value);
if (str === 'auto') return { num: '', unit: 'auto' };
const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/);
if (match) return { num: match[1], unit: match[2] as SizeUnit };
// Pure number = px
if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' };
return { num: '', unit: 'px' };
}
function buildSizeString(num: string, unit: SizeUnit): string | undefined {
if (unit === 'auto') return 'auto';
if (!num) return undefined;
return `${num}${unit}`;
}
type Alignment = 'left' | 'center' | 'right';
function detectAlignment(style: CSSProperties | undefined): Alignment {
if (!style) return 'left';
const ml = style.marginLeft;
const mr = style.marginRight;
if (ml === 'auto' && mr === 'auto') return 'center';
if (ml === 'auto' && mr !== 'auto') return 'right';
return 'left';
}
/* ---------- Settings panel ---------- */
const ImageBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ImageBlockProps,
}));
const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg');
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
// Sizing unit state
const widthParsed = parseSizeValue(props.style?.width);
const [widthUnit, setWidthUnit] = useState<SizeUnit>(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit);
const heightParsed = parseSizeValue(props.style?.height);
const [heightUnit, setHeightUnit] = useState<SizeUnit>(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit);
const maxWidthParsed = parseSizeValue(props.style?.maxWidth);
const [maxWidthUnit, setMaxWidthUnit] = useState<SizeUnit>(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit);
const alignment = detectAlignment(props.style);
const handleUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
}, [setProp]);
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 radiusPresets = ['0', '8px', '16px', '32px', '50%'];
const setPropStyle = useCallback((key: string, value: string | undefined) => {
setProp((p: ImageBlockProps) => {
p.style = { ...p.style, [key]: value };
});
}, [setProp]);
const setAlignment = useCallback((align: Alignment) => {
setProp((p: ImageBlockProps) => {
const s = { ...p.style };
if (align === 'center') {
s.marginLeft = 'auto';
s.marginRight = 'auto';
s.display = 'block';
} else if (align === 'right') {
s.marginLeft = 'auto';
s.marginRight = undefined;
s.display = 'block';
} else {
s.marginLeft = undefined;
s.marginRight = undefined;
s.display = 'block';
}
p.style = s;
});
}, [setProp]);
// Extract friendly filename from URL
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';
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 };
const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' };
const btnStyle = (active: boolean): CSSProperties => ({
flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
});
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Image preview */}
<div>
<label style={labelStyle}>Image Source</label>
{!isPlaceholder ? (
<>
{/* Current image thumbnail + filename + remove */}
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
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(props.src || '')}</span>
</div>
</>
) : (
/* Drop zone when no image set */
<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>
)}
{/* Action buttons: Upload + Browse */}
<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={() => { setProp((p: ImageBlockProps) => { p.src = 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 (collapsed, for advanced users) */}
<div style={{ marginTop: 6 }}>
<input type="text"
value={isPlaceholder ? '' : (props.src || '')}
onChange={(e) => setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })}
placeholder="Or paste image URL..."
style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }}
/>
</div>
</div>
{/* Alt Text */}
<div>
<label style={labelStyle}>Alt Text</label>
<input
type="text"
value={props.alt || ''}
onChange={(e) => setProp((p: ImageBlockProps) => { p.alt = e.target.value; })}
placeholder="Describe the image..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
{/* Width */}
<div>
<label style={labelStyle}>Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={widthParsed.num}
disabled={props.style?.width === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, widthUnit);
setPropStyle('width', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('width', 'auto');
} else {
setWidthUnit(unit);
const num = widthParsed.num || '100';
setPropStyle('width', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* Max Width */}
<div>
<label style={labelStyle}>Max Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={maxWidthParsed.num}
onChange={(e) => {
const val = buildSizeString(e.target.value, maxWidthUnit);
setPropStyle('maxWidth', val || '100%');
}}
placeholder="100%"
style={inputStyle}
/>
<select
value={maxWidthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
setMaxWidthUnit(unit);
const num = maxWidthParsed.num || '100';
setPropStyle('maxWidth', `${num}${unit}`);
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
</select>
</div>
</div>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={heightParsed.num}
disabled={props.style?.height === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, heightUnit);
setPropStyle('height', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('height', 'auto');
} else {
setHeightUnit(unit);
const num = heightParsed.num || '300';
setPropStyle('height', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* Object Fit (visible when both width and height are explicit values) */}
{props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
<div>
<label style={labelStyle}>Object Fit</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
<button
key={fit}
onClick={() => setPropStyle('objectFit', fit)}
style={btnStyle(props.style?.objectFit === fit)}
>
{fit}
</button>
))}
</div>
</div>
)}
{/* Alignment */}
<div>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
</button>
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
</button>
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
</button>
</div>
</div>
{/* Border Radius */}
<div>
<label style={labelStyle}>Border Radius</label>
<div style={{ display: 'flex', gap: 4 }}>
{radiusPresets.map((r) => (
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
style={btnStyle(props.style?.borderRadius === r)}
>{r}</button>
))}
</div>
</div>
</div>
);
};
ImageBlock.craft = {
displayName: 'Image',
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
related: { settings: ImageBlockSettings },
};
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
-76
View File
@@ -54,79 +54,6 @@ export const MapEmbed: UserComponent<MapEmbedProps> = ({
);
};
/* ---------- Settings panel ---------- */
const MapEmbedSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MapEmbedProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const heightPresets = ['300px', '400px', '500px', '600px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Address */}
<div>
<label style={labelStyle}>Address</label>
<input
type="text"
value={props.address || ''}
onChange={(e) => setProp((p: MapEmbedProps) => { p.address = e.target.value; })}
placeholder="Enter an address or location..."
style={inputStyle}
/>
</div>
{/* Zoom */}
<div>
<label style={labelStyle}>Zoom: {props.zoom || 14}</label>
<input
type="range"
min={1}
max={20}
value={props.zoom || 14}
onChange={(e) => setProp((p: MapEmbedProps) => { p.zoom = parseInt(e.target.value, 10); })}
style={{ width: '100%' }}
/>
</div>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: MapEmbedProps) => { p.height = h; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
<input
type="text"
value={props.height || ''}
onChange={(e) => setProp((p: MapEmbedProps) => { p.height = e.target.value; })}
placeholder="e.g. 400px"
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
MapEmbed.craft = {
@@ -142,9 +69,6 @@ MapEmbed.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: MapEmbedSettings,
},
};
/* ---------- HTML export ---------- */
+1 -362
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
@@ -61,28 +61,6 @@ function buildEmbedParams(
return url.toString();
}
/* ---------- Upload helper ---------- */
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;
}
}
/* ---------- Placeholder ---------- */
const VIDEO_PLACEHOLDER = (
@@ -297,342 +275,6 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
);
};
/* ========================================================================
Settings Panel
======================================================================== */
const VideoBlockSettings: React.FC = () => {
const {
actions: { setProp },
props,
} = useNode((node) => ({
props: node.data.props as VideoBlockProps,
}));
const [urlInput, setUrlInput] = useState(props.videoUrl || '');
const fileInputRef = useRef<HTMLInputElement>(null);
const detected = props.videoUrl ? detectVideoType(props.videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
const applyUrl = useCallback(
(url: string) => {
const info = detectVideoType(url);
setProp((p: VideoBlockProps) => {
p.videoUrl = url;
p.videoType = info.type;
p.embedUrl = info.embedUrl;
});
},
[setProp]
);
const handleUpload = useCallback(
async (file: File) => {
const url = await uploadToWhp(file);
if (url) {
setUrlInput(url);
applyUrl(url);
}
},
[applyUrl]
);
const handleBrowse = useCallback(async () => {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
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 videos = data.assets.filter(
(a: any) => (a.type || '').startsWith('video') || (a.name || '').match(/\.(mp4|webm|ogg|mov)$/i)
);
if (videos.length === 0) {
alert('No video assets uploaded yet. Use the Upload button to add one.');
return;
}
const names = videos.map((a: any, i: number) => `${i + 1}. ${a.name}`).join('\n');
const choice = prompt(`Select a video (enter number):\n\n${names}`);
if (choice) {
const idx = parseInt(choice, 10) - 1;
if (videos[idx]) {
setUrlInput(videos[idx].url);
applyUrl(videos[idx].url);
}
}
}
} catch (e) {
console.error('Browse failed:', e);
}
}, [applyUrl]);
const typeBadge = (label: string, color: string) => (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
background: color,
color: '#fff',
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{label}
</span>
);
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%',
padding: '4px 8px',
background: '#27272a',
color: '#e4e4e7',
border: '1px solid #3f3f46',
borderRadius: 4,
fontSize: 12,
};
const checkboxRowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: '#e4e4e7',
cursor: 'pointer',
};
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Video URL */}
<div>
<label style={labelStyle}>Video URL</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applyUrl(urlInput);
}}
placeholder="YouTube, Vimeo, or direct video URL..."
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => applyUrl(urlInput)}
style={{
padding: '4px 12px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#3b82f6',
color: '#fff',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
Apply
</button>
</div>
{/* Detected type badge */}
{detected.type !== 'none' && (
<div style={{ marginTop: 6 }}>
{detected.type === 'youtube' && typeBadge('YouTube', '#dc2626')}
{detected.type === 'vimeo' && typeBadge('Vimeo', '#1ab7ea')}
{detected.type === 'file' && typeBadge('Video File', '#16a34a')}
</div>
)}
</div>
{/* Upload / Browse */}
<div>
<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: '#27272a',
color: '#e4e4e7',
}}
>
<i className="fa fa-folder-open" style={{ marginRight: 4 }} /> Browse
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = '';
}}
/>
</div>
{/* Playback options */}
<div>
<label style={labelStyle}>Playback</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.autoplay ?? false}
onChange={(e) => setProp((p: VideoBlockProps) => { p.autoplay = e.target.checked; })}
/>
Autoplay
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.muted ?? true}
onChange={(e) => setProp((p: VideoBlockProps) => { p.muted = e.target.checked; })}
/>
Muted
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.loop ?? false}
onChange={(e) => setProp((p: VideoBlockProps) => { p.loop = e.target.checked; })}
/>
Loop
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.controls ?? true}
onChange={(e) => setProp((p: VideoBlockProps) => { p.controls = e.target.checked; })}
/>
Show Controls
</label>
</div>
</div>
{/* Mode toggle */}
<div>
<label style={labelStyle}>Display Mode</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = false; })}
style={{
flex: 1,
padding: '6px 10px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: !props.isBackground ? '#3b82f6' : '#27272a',
color: !props.isBackground ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Normal
</button>
<button
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = true; })}
style={{
flex: 1,
padding: '6px 10px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.isBackground ? '#3b82f6' : '#27272a',
color: props.isBackground ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Background
</button>
</div>
</div>
{/* Background mode options */}
{props.isBackground && (
<>
<div>
<label style={labelStyle}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{overlayPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: VideoBlockProps) => { p.overlayColor = c; })}
style={{
width: 24,
height: 24,
borderRadius: 4,
border: '1px solid #3f3f46',
backgroundColor: c,
cursor: 'pointer',
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>
Overlay Opacity: {props.overlayOpacity ?? 50}%
</label>
<input
type="range"
min={0}
max={100}
value={props.overlayOpacity ?? 50}
onChange={(e) =>
setProp((p: VideoBlockProps) => {
p.overlayOpacity = parseInt(e.target.value, 10);
})
}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={labelStyle}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => setProp((p: VideoBlockProps) => { p.innerMaxWidth = e.target.value; })}
style={inputStyle}
/>
</div>
</>
)}
</div>
);
};
/* ========================================================================
Craft Config
======================================================================== */
@@ -658,9 +300,6 @@ VideoBlock.craft = {
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: VideoBlockSettings,
},
};
/* ========================================================================
-154
View File
@@ -1,7 +1,6 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface AccordionItem {
@@ -124,156 +123,6 @@ export const Accordion: UserComponent<AccordionProps> = ({
);
};
/* ---------- Settings panel ---------- */
const AccordionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as AccordionProps,
}));
const items = props.items || defaultItems;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updateItem = (index: number, field: keyof AccordionItem, value: string | boolean) => {
setProp((p: AccordionProps) => {
const updated = [...(p.items || defaultItems)];
updated[index] = { ...updated[index], [field]: value };
p.items = updated;
});
};
const addItem = () => {
setProp((p: AccordionProps) => {
p.items = [...(p.items || defaultItems), { title: 'New Question', content: 'Answer goes here.', isOpen: false }];
});
};
const removeItem = (index: number) => {
setProp((p: AccordionProps) => {
const updated = [...(p.items || defaultItems)];
updated.splice(index, 1);
p.items = updated;
});
};
const colorSwatches = ['#f8fafc', '#f1f5f9', '#e2e8f0', '#ffffff', '#18181b', '#1e293b', '#3b82f6', '#8b5cf6'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={labelStyle}>Header Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorSwatches.map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.headerBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.headerBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Header Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#18181b', '#1f2937', '#374151', '#ffffff', '#e2e8f0', '#3b82f6'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.headerColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.headerColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Content Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.contentBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Border Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#e2e8f0', '#cbd5e1', '#d1d5db', '#3f3f46', '#52525b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.borderColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.borderColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Items</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={item.title} onChange={(e) => updateItem(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeItem(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={item.content}
onChange={(e) => updateItem(i, 'content', e.target.value)}
placeholder="Content"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</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>
</div>
);
};
/* ---------- Craft config ---------- */
Accordion.craft = {
@@ -292,9 +141,6 @@ Accordion.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: AccordionSettings,
},
};
/* ---------- HTML export ---------- */
+1 -77
View File
@@ -1,8 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface CTASectionProps {
@@ -71,78 +70,6 @@ export const CTASection: UserComponent<CTASectionProps> = ({
);
};
/* ---------- Settings panel ---------- */
const CTASectionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CTASectionProps,
}));
const gradientPresets = [
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
const effectiveCtas = normalizeCtas(props);
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.heading = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
<textarea
value={props.description || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.description = e.target.value; })}
rows={2}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
/>
</div>
<CtasEditor
ctas={effectiveCtas}
onChange={(next) => setProp((p: CTASectionProps) => {
p.ctas = next;
p.buttonText = undefined;
p.buttonHref = undefined;
})}
/>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradientPresets.map((g) => (
<button
key={g.label}
onClick={() => setProp((p: CTASectionProps) => { p.gradient = g.value; })}
title={g.label}
style={{
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
background: g.value, cursor: 'pointer',
outline: props.gradient === g.value ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
CTASection.craft = {
@@ -162,9 +89,6 @@ CTASection.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CTASectionSettings,
},
};
/* ---------- HTML export ---------- */
+1 -215
View File
@@ -1,8 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface CallToActionProps {
@@ -113,216 +112,6 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
);
};
/* ---------- Settings panel ---------- */
const CallToActionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CallToActionProps,
}));
const gradientPresets = [
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
const colorPresets = ['#2563eb', '#7c3aed', '#0d9488', '#18181b', '#0f172a', '#1e293b', '#dc2626', '#f97316'];
const buttonColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#ec4899'];
const textColorPresets = ['#ffffff', '#f8fafc', '#e2e8f0', '#18181b', '#1e293b', '#fef3c7'];
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const effectiveCtas = normalizeCtas(props);
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.heading = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
<textarea
value={props.description || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.description = e.target.value; })}
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
<CtasEditor
ctas={effectiveCtas}
onChange={(next) => setProp((p: CallToActionProps) => {
p.ctas = next;
p.buttonText = undefined;
p.buttonHref = undefined;
p.secondaryButtonText = undefined;
p.secondaryButtonHref = undefined;
})}
/>
{/* Background Type */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Type</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['color', 'gradient', 'image'] as const).map((t) => (
<button
key={t}
onClick={() => {
setProp((p: CallToActionProps) => {
p.bgType = t;
if (t === 'color') p.bgValue = '#2563eb';
if (t === 'gradient') p.bgValue = defaultGradient;
if (t === 'image') p.bgValue = '';
});
}}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.bgType === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{t}
</button>
))}
</div>
</div>
{/* Background sub-controls */}
{props.bgType === 'color' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgValue === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'gradient' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradientPresets.map((g) => (
<button
key={g.label}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = g.value; })}
title={g.label}
style={{
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
background: g.value, cursor: 'pointer',
outline: props.bgValue === g.value ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'image' && (
<>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Image URL</label>
<input
type="text"
value={props.bgValue || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.bgValue = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Overlay Opacity: {props.overlayOpacity ?? 0}%
</label>
<input
type="range"
min={0}
max={100}
value={props.overlayOpacity ?? 0}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayOpacity = parseInt(e.target.value); })}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
<input
type="color"
value={props.overlayColor || '#000000'}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
</>
)}
{/* Text Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.textColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.textColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Button Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{buttonColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.buttonColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.buttonColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
CallToAction.craft = {
@@ -348,9 +137,6 @@ CallToAction.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CallToActionSettings,
},
};
/* ---------- HTML export ---------- */
@@ -216,208 +216,6 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
);
};
/* ---------- Settings panel ---------- */
const ContentSliderSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContentSliderProps,
}));
const items = props.slides || defaultSlides;
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const heightPresets = ['300px', '400px', '500px', '600px', '80vh'];
const intervalPresets = [3000, 4000, 5000, 7000, 10000];
const bgPresets = [
'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
'linear-gradient(135deg, #10b981 0%, #059669 100%)',
'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
'linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%)',
'#18181b',
'#0f172a',
'#1e293b',
'#3b82f6',
];
const updateSlide = (index: number, field: keyof Slide, value: string) => {
setProp((p: ContentSliderProps) => {
const updated = [...(p.slides || defaultSlides)];
updated[index] = { ...updated[index], [field]: value };
p.slides = updated;
});
};
const addSlide = () => {
setProp((p: ContentSliderProps) => {
const current = p.slides || defaultSlides;
p.slides = [...current, {
type: 'image',
imageSrc: '',
heading: 'New Slide',
text: 'Add your content here',
buttonText: '',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
}];
});
};
const removeSlide = (index: number) => {
setProp((p: ContentSliderProps) => {
const updated = [...(p.slides || defaultSlides)];
updated.splice(index, 1);
p.slides = updated;
});
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: ContentSliderProps) => { p.height = h; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: props.height === h ? '#fff' : '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
</div>
{/* Autoplay */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.autoplay !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.autoplay = e.target.checked; })}
/>
Autoplay
</label>
</div>
{/* Interval */}
{props.autoplay !== false && (
<div>
<label style={labelStyle}>Interval (ms)</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{intervalPresets.map((ms) => (
<button
key={ms}
onClick={() => setProp((p: ContentSliderProps) => { p.interval = ms; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.interval || 5000) === ms ? '#3b82f6' : '#27272a',
color: (props.interval || 5000) === ms ? '#fff' : '#e4e4e7',
}}
>
{ms / 1000}s
</button>
))}
</div>
</div>
)}
{/* Show Arrows */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showArrows !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.showArrows = e.target.checked; })}
/>
Show Arrows
</label>
</div>
{/* Show Dots */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showDots !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.showDots = e.target.checked; })}
/>
Show Dots
</label>
</div>
{/* Slides */}
<div>
<label style={labelStyle}>Slides</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((slide, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#a1a1aa', flex: 'none', width: 18 }}>{i + 1}.</span>
<select
value={slide.type}
onChange={(e) => updateSlide(i, 'type', e.target.value)}
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
>
<option value="image">Image</option>
<option value="content">Content</option>
</select>
<input type="text" value={slide.heading || ''} onChange={(e) => updateSlide(i, 'heading', e.target.value)} placeholder="Heading" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeSlide(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<input type="text" value={slide.imageSrc || ''} onChange={(e) => updateSlide(i, 'imageSrc', e.target.value)} placeholder="Image URL (optional)" style={inputStyle} />
<input type="text" value={slide.text || ''} onChange={(e) => updateSlide(i, 'text', e.target.value)} placeholder="Text" style={inputStyle} />
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={slide.buttonText || ''} onChange={(e) => updateSlide(i, 'buttonText', e.target.value)} placeholder="Button text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={slide.buttonHref || ''} onChange={(e) => updateSlide(i, 'buttonHref', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
</div>
<div>
<span style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Background</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((bg) => (
<button
key={bg}
onClick={() => updateSlide(i, 'bgColor', bg)}
style={{
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
background: bg, cursor: 'pointer',
outline: slide.bgColor === bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
))}
</div>
<button
onClick={addSlide}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Slide
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ContentSlider.craft = {
@@ -436,9 +234,6 @@ ContentSlider.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ContentSliderSettings,
},
};
/* ---------- HTML export ---------- */
+1 -107
View File
@@ -1,7 +1,6 @@
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
import React, { CSSProperties, useEffect, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface CountdownProps {
@@ -128,108 +127,6 @@ export const Countdown: UserComponent<CountdownProps> = ({
);
};
/* ---------- Settings panel ---------- */
const CountdownSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CountdownProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const colorPresets = ['#ffffff', '#f8fafc', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
const bgPresets = ['#18181b', '#0f172a', '#1e293b', '#1e1b4b', '#042f2e', '#27272a', '#ffffff', '#f8fafc'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
{/* Target date */}
<div>
<label style={labelStyle}>Target Date</label>
<input
type="date"
value={props.targetDate || DEFAULT_TARGET}
onChange={(e) => setProp((p: CountdownProps) => { p.targetDate = e.target.value; })}
style={inputStyle}
/>
</div>
{/* Heading */}
<div>
<label style={labelStyle}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CountdownProps) => { p.heading = e.target.value; })}
placeholder="Coming Soon"
style={inputStyle}
/>
</div>
{/* Digit color */}
<div>
<label style={labelStyle}>Digit Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.digitColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.digitColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label color */}
<div>
<label style={labelStyle}>Label Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.labelColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.labelColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background color */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.bgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Countdown.craft = {
@@ -248,9 +145,6 @@ Countdown.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CountdownSettings,
},
};
/* ---------- HTML export ---------- */
@@ -1,7 +1,6 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface FeatureItem {
@@ -15,25 +14,6 @@ interface FeatureItem {
buttonUrl?: string;
}
/* ---------- Image upload helper (same as Navbar/ImageBlock) ---------- */
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; }
}
interface FeaturesGridProps {
features?: FeatureItem[];
style?: CSSProperties;
@@ -111,152 +91,6 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
);
};
/* ---------- Settings panel ---------- */
const FeaturesGridSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FeaturesGridProps,
}));
const features = props.features || defaultFeatures;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateFeature = (index: number, field: keyof FeatureItem, value: string) => {
setProp((p: FeaturesGridProps) => {
const updated = [...(p.features || defaultFeatures)];
updated[index] = { ...updated[index], [field]: value };
p.features = updated;
});
};
const handleImageUpload = async (index: number, file: File) => {
const url = await uploadToWhp(file);
if (url) updateFeature(index, 'image', url);
};
const addFeature = () => {
setProp((p: FeaturesGridProps) => {
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }];
});
};
const removeFeature = (index: number) => {
setProp((p: FeaturesGridProps) => {
const updated = [...(p.features || defaultFeatures)];
updated.splice(index, 1);
p.features = updated;
});
};
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FeaturesGridProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Features</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(Array.isArray(features) ? features : []).map((feat, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={feat.icon} onChange={(e) => updateFeature(i, 'icon', e.target.value)} placeholder="Icon" style={{ ...inputStyle, width: 40, flex: 'none', textAlign: 'center' }} />
<input type="text" value={feat.title} onChange={(e) => updateFeature(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeFeature(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={feat.description}
onChange={(e) => updateFeature(i, 'description', e.target.value)}
placeholder="Description"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
{/* Icon / Image toggle */}
<div style={{ display: 'flex', gap: 4 }}>
{(['icon', 'image'] as const).map((mt) => {
const active = (feat.mediaType || 'icon') === mt;
return (
<button
key={mt}
onClick={() => updateFeature(i, 'mediaType', mt)}
style={{
flex: 1, padding: '3px 6px', fontSize: 11, cursor: 'pointer',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
}}
>
{mt === 'icon' ? 'Icon' : 'Image'}
</button>
);
})}
</div>
{(feat.mediaType || 'icon') === 'image' ? (
<>
<div style={{ display: 'flex', gap: 4 }}>
<label
style={{ padding: '3px 6px', fontSize: 11, cursor: 'pointer', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, flex: 'none', whiteSpace: 'nowrap' }}
>
Upload
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleImageUpload(i, f); }}
/>
</label>
<input type="text" value={feat.image || ''} onChange={(e) => updateFeature(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
</div>
<input type="text" value={feat.imageAlt || ''} onChange={(e) => updateFeature(i, 'imageAlt', e.target.value)} placeholder="Alt text (optional)" style={inputStyle} />
</>
) : null}
{/* Button (optional) */}
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginTop: 2 }}>Button (optional)</label>
<input type="text" value={feat.buttonText || ''} onChange={(e) => updateFeature(i, 'buttonText', e.target.value)} placeholder="Button text" style={inputStyle} />
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => updateFeature(i, 'buttonUrl', e.target.value)} placeholder="https://... or /page" style={inputStyle} />
</div>
))}
</div>
<button
onClick={addFeature}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Feature
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
FeaturesGrid.craft = {
@@ -271,9 +105,6 @@ FeaturesGrid.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FeaturesGridSettings,
},
};
/* ---------- HTML export ---------- */
-153
View File
@@ -104,156 +104,6 @@ export const Gallery: UserComponent<GalleryProps> = ({
);
};
/* ---------- Settings panel ---------- */
const GallerySettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as GalleryProps,
}));
const images = props.images || defaultImages;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updateImage = (index: number, field: keyof GalleryImage, value: string) => {
setProp((p: GalleryProps) => {
const updated = [...(p.images || defaultImages)];
updated[index] = { ...updated[index], [field]: value };
p.images = updated;
});
};
const addImage = () => {
setProp((p: GalleryProps) => {
const current = p.images || defaultImages;
p.images = [...current, { src: placeholderSvg(current.length), alt: `Gallery image ${current.length + 1}`, caption: '' }];
});
};
const removeImage = (index: number) => {
setProp((p: GalleryProps) => {
const updated = [...(p.images || defaultImages)];
updated.splice(index, 1);
p.images = updated;
});
};
const columnOptions = [2, 3, 4, 5, 6];
const gapOptions = ['8px', '12px', '16px', '24px', '32px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
<button
key={c}
onClick={() => setProp((p: GalleryProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{columnOptions.map((n) => (
<button
key={n}
onClick={() => setProp((p: GalleryProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.columns === n ? '#3b82f6' : '#27272a',
color: props.columns === n ? '#fff' : '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4 }}>
{gapOptions.map((g) => (
<button
key={g}
onClick={() => setProp((p: GalleryProps) => { p.gap = g; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: props.gap === g ? '#fff' : '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.lightbox || false}
onChange={(e) => setProp((p: GalleryProps) => { p.lightbox = e.target.checked; })}
/>
Lightbox (click to enlarge in exported HTML)
</label>
</div>
<div>
<label style={labelStyle}>Images</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{images.map((img, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<img
src={img.src}
alt=""
style={{ width: 32, height: 32, objectFit: 'cover', borderRadius: 4, flexShrink: 0, backgroundColor: '#27272a' }}
/>
<input type="text" value={img.src} onChange={(e) => updateImage(i, 'src', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeImage(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={img.alt} onChange={(e) => updateImage(i, 'alt', e.target.value)} placeholder="Alt text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={img.caption || ''} onChange={(e) => updateImage(i, 'caption', e.target.value)} placeholder="Caption" style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
))}
</div>
<button
onClick={addImage}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Image
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Gallery.craft = {
@@ -270,9 +120,6 @@ Gallery.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: GallerySettings,
},
};
/* ---------- HTML export ---------- */
+1 -214
View File
@@ -1,8 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface HeroProps {
@@ -163,215 +162,6 @@ export const HeroSimple: UserComponent<HeroProps> = ({
);
};
/* ---------- Settings panel ---------- */
const inputStyle: React.CSSProperties = {
width: '100%', padding: '6px 8px', background: '#27272a',
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const labelStyle: React.CSSProperties = {
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
};
const btnStyle = (active: boolean): React.CSSProperties => ({
flex: 1, padding: '6px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
fontWeight: active ? 600 : 400,
});
const HeroSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HeroProps,
}));
const effectiveCtas = normalizeCtas(props);
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
<AnchorIdField />
{/* Content */}
<div>
<label style={labelStyle}>Heading</label>
<input type="text" value={props.heading || ''} onChange={(e) => setProp((p: HeroProps) => { p.heading = e.target.value; })} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Subtitle</label>
<textarea value={props.subtitle || ''} onChange={(e) => setProp((p: HeroProps) => { p.subtitle = e.target.value; })} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
</div>
{/* Dynamic CTAs */}
<CtasEditor
ctas={effectiveCtas}
onChange={(next) => setProp((p: HeroProps) => {
p.ctas = next;
// Once the user touches CTAs, the legacy fields are no longer
// authoritative — clear them so the array is the only source.
p.buttonText = undefined;
p.buttonHref = undefined;
p.secondaryButtonText = undefined;
p.secondaryButtonHref = undefined;
})}
/>
{/* Background Type */}
<div>
<label style={labelStyle}>Background Type</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
<button key={t} onClick={() => setProp((p: HeroProps) => { p.bgType = t; })}
style={btnStyle(props.bgType === t)}>
{t === 'color' ? 'Color' : t === 'gradient' ? 'Gradient' : t === 'image' ? 'Image' : 'Video'}
</button>
))}
</div>
</div>
{/* Background controls based on type */}
{props.bgType === 'color' && (
<div>
<label style={labelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={props.bgColor || '#1e293b'}
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.bgColor || '#1e293b'}
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
)}
{props.bgType === 'gradient' && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>From</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={props.bgGradientFrom || '#667eea'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.bgGradientFrom || '#667eea'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
style={{ ...inputStyle, fontSize: 10 }} />
</div>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>To</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={props.bgGradientTo || '#764ba2'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.bgGradientTo || '#764ba2'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
style={{ ...inputStyle, fontSize: 10 }} />
</div>
</div>
</div>
<div>
<label style={labelStyle}>Angle: {props.bgGradientAngle || 135}°</label>
<input type="range" min={0} max={360} value={props.bgGradientAngle || 135}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientAngle = parseInt(e.target.value); })}
style={{ width: '100%' }} />
</div>
</>
)}
{props.bgType === 'image' && (
<div>
<label style={labelStyle}>Background Image URL</label>
<input type="text" value={props.bgImage || ''} placeholder="https://..."
onChange={(e) => setProp((p: HeroProps) => { p.bgImage = e.target.value; })} style={inputStyle} />
</div>
)}
{props.bgType === 'video' && (
<div>
<label style={labelStyle}>Background Video URL</label>
<input type="text" value={props.bgVideo || ''} placeholder="https://...mp4"
onChange={(e) => setProp((p: HeroProps) => { p.bgVideo = e.target.value; })} style={inputStyle} />
</div>
)}
{/* Overlay */}
{(props.bgType === 'image' || props.bgType === 'video') && (
<div>
<label style={labelStyle}>Overlay ({props.overlayOpacity || 0}%)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={props.overlayColor || '#000000'}
onChange={(e) => setProp((p: HeroProps) => { p.overlayColor = e.target.value; })}
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="range" min={0} max={100} value={props.overlayOpacity || 0}
onChange={(e) => setProp((p: HeroProps) => { p.overlayOpacity = parseInt(e.target.value); })}
style={{ flex: 1 }} />
</div>
</div>
)}
{/* Text & Button Colors */}
<div>
<label style={labelStyle}>Text Color</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={props.textColor || '#ffffff'}
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.textColor || '#ffffff'}
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Button BG</label>
<input type="color" value={props.buttonBgColor || '#3b82f6'}
onChange={(e) => setProp((p: HeroProps) => { p.buttonBgColor = e.target.value; })}
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Button Text</label>
<input type="color" value={props.buttonTextColor || '#ffffff'}
onChange={(e) => setProp((p: HeroProps) => { p.buttonTextColor = e.target.value; })}
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
</div>
</div>
{/* Layout */}
<div>
<label style={labelStyle}>Min Height</label>
<div style={{ display: 'flex', gap: 4 }}>
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
<button key={h} onClick={() => setProp((p: HeroProps) => { p.minHeight = h; })}
style={btnStyle(props.minHeight === h)}>{h === '100vh' ? 'Full' : h}</button>
))}
</div>
</div>
<div>
<label style={labelStyle}>Vertical Align</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['top', 'center', 'bottom'] as const).map((v) => (
<button key={v} onClick={() => setProp((p: HeroProps) => { p.verticalAlign = v; })}
style={btnStyle(props.verticalAlign === v)}>{v}</button>
))}
</div>
</div>
<div>
<label style={labelStyle}>Text Align</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} onClick={() => setProp((p: HeroProps) => { p.textAlign = a; })}
style={btnStyle(props.textAlign === a)}>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
HeroSimple.craft = {
@@ -405,9 +195,6 @@ HeroSimple.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: HeroSettings,
},
};
/* ---------- HTML export ---------- */
@@ -88,199 +88,6 @@ export const NumberCounter: UserComponent<NumberCounterProps> = ({
);
};
/* ---------- Settings panel ---------- */
const NumberCounterSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as NumberCounterProps,
}));
const items = props.counters || defaultCounters;
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const columnOptions = [2, 3, 4, 5, 6];
const sizePresets = ['32px', '40px', '48px', '56px', '64px'];
const numberColorPresets = ['#3b82f6', '#10b981', '#8b5cf6', '#ef4444', '#f59e0b', '#18181b', '#ec4899', '#0ea5e9'];
const labelColorPresets = ['#6b7280', '#374151', '#9ca3af', '#a1a1aa', '#64748b', '#18181b'];
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
const updateCounter = (index: number, field: keyof Counter, value: string | number) => {
setProp((p: NumberCounterProps) => {
const updated = [...(p.counters || defaultCounters)];
updated[index] = { ...updated[index], [field]: value };
p.counters = updated;
});
};
const addCounter = () => {
setProp((p: NumberCounterProps) => {
p.counters = [...(p.counters || defaultCounters), { number: 100, suffix: '+', label: 'New Stat' }];
});
};
const removeCounter = (index: number) => {
setProp((p: NumberCounterProps) => {
const updated = [...(p.counters || defaultCounters)];
updated.splice(index, 1);
p.counters = updated;
});
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Columns */}
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{columnOptions.map((n) => (
<button
key={n}
onClick={() => setProp((p: NumberCounterProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.columns || 4) === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
{/* Number Size */}
<div>
<label style={labelStyle}>Number Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: NumberCounterProps) => { p.numberSize = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.numberSize || '48px') === s ? '#3b82f6' : '#27272a',
color: (props.numberSize || '48px') === s ? '#fff' : '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Number Color */}
<div>
<label style={labelStyle}>Number Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{numberColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.numberColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.numberColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label Color */}
<div>
<label style={labelStyle}>Label Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{labelColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.labelColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.labelColor || '#6b7280') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Counters */}
<div>
<label style={labelStyle}>Counters</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((counter, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="number"
value={counter.number}
onChange={(e) => updateCounter(i, 'number', parseInt(e.target.value) || 0)}
placeholder="Number"
style={{ ...inputStyle, width: 70, flex: 'none' }}
/>
<input
type="text"
value={counter.suffix}
onChange={(e) => updateCounter(i, 'suffix', e.target.value)}
placeholder="Suffix"
style={{ ...inputStyle, width: 40, flex: 'none' }}
/>
<input
type="text"
value={counter.label}
onChange={(e) => updateCounter(i, 'label', e.target.value)}
placeholder="Label"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeCounter(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
</div>
))}
</div>
<button
onClick={addCounter}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Counter
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
NumberCounter.craft = {
@@ -298,9 +105,6 @@ NumberCounter.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: NumberCounterSettings,
},
};
/* ---------- HTML export ---------- */
@@ -1,7 +1,6 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface PricingPlan {
@@ -198,189 +197,6 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
);
};
/* ---------- Settings panel ---------- */
const PricingTableSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as PricingTableProps,
}));
const plans = props.plans || defaultPlans;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updatePlan = (index: number, field: keyof PricingPlan, value: any) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[index] = { ...updated[index], [field]: value };
p.plans = updated;
});
};
const updateFeature = (planIndex: number, featureIndex: number, value: string) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features[featureIndex] = value;
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addFeature = (planIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[planIndex] = { ...updated[planIndex], features: [...updated[planIndex].features, 'New Feature'] };
p.plans = updated;
});
};
const removeFeature = (planIndex: number, featureIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features.splice(featureIndex, 1);
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addPlan = () => {
setProp((p: PricingTableProps) => {
p.plans = [...(p.plans || defaultPlans), {
name: 'New Plan',
price: '$19',
period: '/month',
features: ['Feature 1', 'Feature 2'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: false,
}];
});
};
const removePlan = (index: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated.splice(index, 1);
p.plans = updated;
});
};
const bgPresets = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#0f172a', '#7c3aed'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Featured Plan Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { p.featuredBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.featuredBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Plans</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{plans.map((plan, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.name} onChange={(e) => updatePlan(i, 'name', e.target.value)} placeholder="Plan Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removePlan(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.price} onChange={(e) => updatePlan(i, 'price', e.target.value)} placeholder="$29" style={{ ...inputStyle, width: '60px', flex: 'none' }} />
<input type="text" value={plan.period} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={{ ...inputStyle, width: '70px', flex: 'none' }} />
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', marginLeft: 'auto' }}>
<input
type="checkbox"
checked={plan.isFeatured}
onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)}
/>
Featured
</label>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.buttonText} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} placeholder="Button Text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={plan.buttonHref} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} placeholder="URL" style={{ ...inputStyle, flex: 1 }} />
</div>
{/* Features */}
<div style={{ marginTop: 4 }}>
<span style={{ fontSize: 10, color: '#71717a' }}>Features:</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
{(Array.isArray(plan.features) ? plan.features : []).map((feat, fi) => (
<div key={fi} style={{ display: 'flex', gap: 2 }}>
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeFeature(i, fi)}
style={{ padding: '1px 4px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, cursor: 'pointer' }}
>
X
</button>
</div>
))}
</div>
<button
onClick={() => addFeature(i)}
style={{ marginTop: 2, width: '100%', padding: '3px', fontSize: 10, background: '#27272a', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer' }}
>
+ Feature
</button>
</div>
</div>
))}
</div>
<button
onClick={addPlan}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Plan
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
PricingTable.craft = {
@@ -397,9 +213,6 @@ PricingTable.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: PricingTableSettings,
},
};
/* ---------- HTML export ---------- */
-172
View File
@@ -1,7 +1,6 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface TabItem {
@@ -102,174 +101,6 @@ export const Tabs: UserComponent<TabsProps> = ({
);
};
/* ---------- Settings panel ---------- */
const TabsSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TabsProps,
}));
const tabs = props.tabs || defaultTabs;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updateTab = (index: number, field: keyof TabItem, value: string) => {
setProp((p: TabsProps) => {
const updated = [...(p.tabs || defaultTabs)];
updated[index] = { ...updated[index], [field]: value };
p.tabs = updated;
});
};
const addTab = () => {
setProp((p: TabsProps) => {
p.tabs = [...(p.tabs || defaultTabs), { label: 'New Tab', content: 'Tab content goes here.' }];
});
};
const removeTab = (index: number) => {
setProp((p: TabsProps) => {
const updated = [...(p.tabs || defaultTabs)];
updated.splice(index, 1);
p.tabs = updated;
});
};
const colorSwatches = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#ffffff', '#f1f5f9'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={labelStyle}>Active Tab Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorSwatches.map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.activeTabBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.activeTabBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Active Tab Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#18181b', '#1f2937', '#e2e8f0'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.activeTabColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.activeTabColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Inactive Tab Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#f1f5f9', '#e2e8f0', '#f8fafc', '#ffffff', '#27272a', '#18181b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.inactiveTabBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.inactiveTabBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Inactive Tab Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#64748b', '#94a3b8', '#18181b', '#ffffff'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.inactiveTabColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.inactiveTabColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Content Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.contentBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Tabs</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{tabs.map((tab, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={tab.label} onChange={(e) => updateTab(i, 'label', e.target.value)} placeholder="Label" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeTab(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={tab.content}
onChange={(e) => updateTab(i, 'content', e.target.value)}
placeholder="Content"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
))}
</div>
<button
onClick={addTab}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Tab
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Tabs.craft = {
@@ -289,9 +120,6 @@ Tabs.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TabsSettings,
},
};
/* ---------- HTML export ---------- */
@@ -1,7 +1,6 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface Testimonial {
@@ -149,209 +148,6 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
);
};
/* ---------- Settings panel ---------- */
const TestimonialsSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TestimonialsProps,
}));
const items = props.testimonials || defaultTestimonials;
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateTestimonial = (index: number, field: keyof Testimonial, value: string | number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated[index] = { ...updated[index], [field]: value };
p.testimonials = updated;
});
};
const addTestimonial = () => {
setProp((p: TestimonialsProps) => {
p.testimonials = [...(p.testimonials || defaultTestimonials), { quote: 'Great experience!', name: 'New Person', title: 'Role', rating: 5 }];
});
};
const removeTestimonial = (index: number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated.splice(index, 1);
p.testimonials = updated;
});
};
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
const cardBgPresets = ['#f8fafc', '#ffffff', '#f1f5f9', '#e2e8f0', '#27272a', '#1e293b'];
const starColorPresets = ['#f59e0b', '#eab308', '#ef4444', '#3b82f6', '#10b981', '#8b5cf6'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'grid'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'grid' ? '#3b82f6' : '#27272a',
color: props.layout === 'grid' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Grid
</button>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'single'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'single' ? '#3b82f6' : '#27272a',
color: props.layout === 'single' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Single
</button>
</div>
</div>
{/* Columns (only for grid) */}
{props.layout === 'grid' && (
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{[1, 2, 3, 4].map((n) => (
<button
key={n}
onClick={() => setProp((p: TestimonialsProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.columns === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
)}
{/* Section background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Card background */}
<div>
<label style={labelStyle}>Card Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{cardBgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.cardBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.cardBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Star color */}
<div>
<label style={labelStyle}>Star Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{starColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.starColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.starColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Testimonials list */}
<div>
<label style={labelStyle}>Testimonials</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((t, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={t.name} onChange={(e) => updateTestimonial(i, 'name', e.target.value)} placeholder="Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeTestimonial(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<input type="text" value={t.title} onChange={(e) => updateTestimonial(i, 'title', e.target.value)} placeholder="Title/Role" style={inputStyle} />
<textarea
value={t.quote}
onChange={(e) => updateTestimonial(i, 'quote', e.target.value)}
placeholder="Quote..."
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: '#a1a1aa' }}>Rating:</span>
{[1, 2, 3, 4, 5].map((n) => (
<i
key={n}
className={`fa ${n <= t.rating ? 'fa-star' : 'fa-star-o'}`}
onClick={() => updateTestimonial(i, 'rating', n)}
style={{ color: '#f59e0b', cursor: 'pointer', fontSize: 14 }}
/>
))}
</div>
</div>
))}
</div>
<button
onClick={addTestimonial}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Testimonial
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Testimonials.craft = {
@@ -370,9 +166,6 @@ Testimonials.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TestimonialsSettings,
},
};
/* ---------- HTML export ---------- */
+1 -113
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties } from 'react';
import { CSSProperties } from 'react';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
export type CtaVariant = 'primary' | 'outline' | 'ghost';
@@ -86,115 +86,3 @@ export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): strin
}).join('');
}
/* ---------- CTAs editor (settings UI) ---------- */
interface CtasEditorProps {
ctas: CtaButton[];
/** Called whenever the user mutates the array. Sections wire this via setProp. */
onChange: (next: CtaButton[]) => void;
/** Max items the user can add. Default 4. */
max?: number;
}
const inputStyle: CSSProperties = {
width: '100%', padding: '6px 8px', background: '#27272a',
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const labelStyle: CSSProperties = {
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
};
export const CtasEditor: React.FC<CtasEditorProps> = ({ ctas, onChange, max = 4 }) => {
const update = (i: number, patch: Partial<CtaButton>) => {
const next = ctas.slice();
next[i] = { ...next[i], ...patch };
onChange(next);
};
const remove = (i: number) => onChange(ctas.filter((_, j) => j !== i));
const add = () => {
if (ctas.length >= max) return;
onChange([...ctas, { text: 'New button', href: '#', variant: ctas.length === 0 ? 'primary' : 'outline' }]);
};
const move = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= ctas.length) return;
const next = ctas.slice();
[next[i], next[j]] = [next[j], next[i]];
onChange(next);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ fontSize: 11, color: '#a1a1aa', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Buttons ({ctas.length})
</div>
{ctas.length === 0 && (
<div style={{ fontSize: 11, color: '#71717a', fontStyle: 'italic', padding: '8px 0' }}>
No buttons. Click "Add button" to insert one.
</div>
)}
{ctas.map((cta, i) => (
<div key={i} style={{
background: '#18181b', border: '1px solid #3f3f46', borderRadius: 6,
padding: 10, display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 10, color: '#a1a1aa', fontWeight: 600, flex: 1 }}>Button {i + 1}</span>
<button onClick={() => move(i, -1)} disabled={i === 0} title="Move up"
style={iconBtn(i === 0)}></button>
<button onClick={() => move(i, 1)} disabled={i === ctas.length - 1} title="Move down"
style={iconBtn(i === ctas.length - 1)}></button>
<button onClick={() => remove(i)} title="Remove"
style={{ ...iconBtn(false), color: '#fca5a5' }}></button>
</div>
<div>
<label style={labelStyle}>Text</label>
<input type="text" value={cta.text} onChange={(e) => update(i, { text: e.target.value })} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>URL</label>
<input type="text" value={cta.href} onChange={(e) => update(i, { href: e.target.value })}
placeholder="https://… or #anchor" style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Style</label>
<select value={cta.variant || 'primary'}
onChange={(e) => update(i, { variant: e.target.value as CtaVariant })}
style={{ ...inputStyle, padding: '5px 6px' }}>
<option value="primary">Primary (filled)</option>
<option value="outline">Outline</option>
<option value="ghost">Ghost (text)</option>
</select>
</div>
<div style={{ flex: '0 0 auto', display: 'flex', alignItems: 'flex-end' }}>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={cta.target === '_blank'}
onChange={(e) => update(i, { target: e.target.checked ? '_blank' : undefined })} />
New tab
</label>
</div>
</div>
</div>
))}
{ctas.length < max && (
<button onClick={add} style={{
padding: '8px 12px', fontSize: 12, fontWeight: 600,
color: '#3b82f6', background: 'rgba(59,130,246,0.1)',
border: '1px dashed #3b82f6', borderRadius: 4, cursor: 'pointer',
}}>
+ Add button{ctas.length === 0 ? '' : ` (${max - ctas.length} more)`}
</button>
)}
</div>
);
};
function iconBtn(disabled: boolean): CSSProperties {
return {
width: 22, height: 22, fontSize: 11,
background: '#27272a', color: disabled ? '#52525b' : '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4,
cursor: disabled ? 'not-allowed' : 'pointer',
};
}