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:
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ButtonLink.craft = {
|
ButtonLink.craft = {
|
||||||
@@ -212,9 +68,6 @@ ButtonLink.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ButtonLinkSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Divider.craft = {
|
Divider.craft = {
|
||||||
@@ -101,9 +48,6 @@ Divider.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: DividerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Footer.craft = {
|
Footer.craft = {
|
||||||
@@ -135,9 +74,6 @@ Footer.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FooterSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
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';
|
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 = {
|
Heading.craft = {
|
||||||
displayName: 'Heading',
|
displayName: 'Heading',
|
||||||
props: {
|
props: {
|
||||||
@@ -168,9 +95,6 @@ Heading.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: HeadingSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
|
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
HtmlBlock.craft = {
|
HtmlBlock.craft = {
|
||||||
@@ -132,9 +62,6 @@ HtmlBlock.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: HtmlBlockSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -14,15 +14,6 @@ interface IconProps {
|
|||||||
style?: CSSProperties;
|
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 {
|
function getBgBorderRadius(shape: string): string {
|
||||||
if (shape === 'circle') return '50%';
|
if (shape === 'circle') return '50%';
|
||||||
if (shape === 'rounded') return '8px';
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Icon.craft = {
|
Icon.craft = {
|
||||||
@@ -281,9 +102,6 @@ Icon.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: IconSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||||
@@ -19,25 +19,6 @@ interface LogoProps {
|
|||||||
style?: CSSProperties;
|
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 ---------- */
|
/* ---------- Component ---------- */
|
||||||
|
|
||||||
export const Logo: UserComponent<LogoProps> = ({
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Logo.craft = {
|
Logo.craft = {
|
||||||
@@ -376,9 +94,6 @@ Logo.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: LogoSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { usePages } from '../../state/PageContext';
|
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Types ---------- */
|
/* ---------- 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Menu.craft = {
|
Menu.craft = {
|
||||||
@@ -440,9 +120,6 @@ Menu.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: MenuSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { usePages } from '../../state/PageContext';
|
|
||||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
@@ -52,25 +51,6 @@ const PADDING_PRESETS = [
|
|||||||
{ label: 'Spacious', value: '24px 48px' },
|
{ 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 ---------- */
|
/* ---------- Component ---------- */
|
||||||
|
|
||||||
export const Navbar: UserComponent<NavbarProps> = ({
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Navbar.craft = {
|
Navbar.craft = {
|
||||||
@@ -815,9 +206,6 @@ Navbar.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: NavbarSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
SearchBar.craft = {
|
SearchBar.craft = {
|
||||||
@@ -164,9 +108,6 @@ SearchBar.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SearchBarSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ const platformLabels: Record<string, string> = {
|
|||||||
twitch: 'Twitch',
|
twitch: 'Twitch',
|
||||||
};
|
};
|
||||||
|
|
||||||
const allPlatforms = Object.keys(platformIcons);
|
|
||||||
|
|
||||||
const defaultLinks: SocialLink[] = [
|
const defaultLinks: SocialLink[] = [
|
||||||
{ platform: 'facebook', url: '#' },
|
{ platform: 'facebook', url: '#' },
|
||||||
{ platform: 'twitter', 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
SocialLinks.craft = {
|
SocialLinks.craft = {
|
||||||
@@ -388,9 +157,6 @@ SocialLinks.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SocialLinksSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Spacer.craft = {
|
Spacer.craft = {
|
||||||
@@ -91,9 +46,6 @@ Spacer.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SpacerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
StarRating.craft = {
|
StarRating.craft = {
|
||||||
@@ -193,9 +92,6 @@ StarRating.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: StarRatingSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
|
||||||
import { TypographyControl } from '../../ui/TypographyControl';
|
|
||||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
|
||||||
|
|
||||||
interface TextBlockProps {
|
interface TextBlockProps {
|
||||||
text?: string;
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
TextBlock.craft = {
|
TextBlock.craft = {
|
||||||
@@ -144,9 +89,6 @@ TextBlock.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TextBlockSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 Option 2 Option 3"
|
|
||||||
style={{ ...inputStyle, resize: 'vertical' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={addField}
|
|
||||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
+ Add Field
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- Craft config ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ContactForm.craft = {
|
ContactForm.craft = {
|
||||||
@@ -387,9 +163,6 @@ ContactForm.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ContactFormSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
FormButton.craft = {
|
FormButton.craft = {
|
||||||
@@ -158,9 +63,6 @@ FormButton.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FormButtonSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
FormContainer.craft = {
|
FormContainer.craft = {
|
||||||
@@ -152,9 +64,6 @@ FormContainer.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FormContainerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
InputField.craft = {
|
InputField.craft = {
|
||||||
@@ -158,9 +83,6 @@ InputField.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: InputFieldSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
SubscribeForm.craft = {
|
SubscribeForm.craft = {
|
||||||
@@ -242,9 +116,6 @@ SubscribeForm.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SubscribeFormSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
TextareaField.craft = {
|
TextareaField.craft = {
|
||||||
@@ -160,9 +85,6 @@ TextareaField.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TextareaFieldSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeAttr } from '../../utils/escape';
|
import { escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface BackgroundSectionProps {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
BackgroundSection.craft = {
|
BackgroundSection.craft = {
|
||||||
@@ -175,9 +87,6 @@ BackgroundSection.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: BackgroundSectionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeAttr } from '../../utils/escape';
|
import { escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
type SplitOption =
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ColumnLayout.craft = {
|
ColumnLayout.craft = {
|
||||||
@@ -283,9 +110,6 @@ ColumnLayout.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ColumnLayoutSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
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';
|
import { escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface ContainerProps {
|
interface ContainerProps {
|
||||||
@@ -70,237 +66,6 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
return el;
|
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}><{t}></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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Container.craft = {
|
Container.craft = {
|
||||||
@@ -317,9 +82,6 @@ Container.craft = {
|
|||||||
canMoveIn: () => true,
|
canMoveIn: () => true,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ContainerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeAttr } from '../../utils/escape';
|
import { escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Shape Divider SVG Paths ---------- */
|
/* ---------- 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',
|
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 {
|
interface SectionProps {
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
innerMaxWidth?: string;
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Section.craft = {
|
Section.craft = {
|
||||||
@@ -346,9 +151,6 @@ Section.craft = {
|
|||||||
canMoveIn: () => true,
|
canMoveIn: () => true,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SectionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
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 = {
|
ImageBlock.craft = {
|
||||||
displayName: 'Image',
|
displayName: 'Image',
|
||||||
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
||||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||||
related: { settings: ImageBlockSettings },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
MapEmbed.craft = {
|
MapEmbed.craft = {
|
||||||
@@ -142,9 +69,6 @@ MapEmbed.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: MapEmbedSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from '../layout/Container';
|
import { Container } from '../layout/Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
@@ -61,28 +61,6 @@ function buildEmbedParams(
|
|||||||
return url.toString();
|
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 ---------- */
|
/* ---------- Placeholder ---------- */
|
||||||
|
|
||||||
const VIDEO_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
|
Craft Config
|
||||||
======================================================================== */
|
======================================================================== */
|
||||||
@@ -658,9 +300,6 @@ VideoBlock.craft = {
|
|||||||
canMoveIn: () => true,
|
canMoveIn: () => true,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: VideoBlockSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface AccordionItem {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Accordion.craft = {
|
Accordion.craft = {
|
||||||
@@ -292,9 +141,6 @@ Accordion.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: AccordionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface CTASectionProps {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
CTASection.craft = {
|
CTASection.craft = {
|
||||||
@@ -162,9 +89,6 @@ CTASection.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: CTASectionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface CallToActionProps {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
CallToAction.craft = {
|
CallToAction.craft = {
|
||||||
@@ -348,9 +137,6 @@ CallToAction.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: CallToActionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ContentSlider.craft = {
|
ContentSlider.craft = {
|
||||||
@@ -436,9 +234,6 @@ ContentSlider.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ContentSliderSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface CountdownProps {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Countdown.craft = {
|
Countdown.craft = {
|
||||||
@@ -248,9 +145,6 @@ Countdown.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: CountdownSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface FeatureItem {
|
interface FeatureItem {
|
||||||
@@ -15,25 +14,6 @@ interface FeatureItem {
|
|||||||
buttonUrl?: string;
|
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 {
|
interface FeaturesGridProps {
|
||||||
features?: FeatureItem[];
|
features?: FeatureItem[];
|
||||||
style?: CSSProperties;
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
FeaturesGrid.craft = {
|
FeaturesGrid.craft = {
|
||||||
@@ -271,9 +105,6 @@ FeaturesGrid.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FeaturesGridSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Gallery.craft = {
|
Gallery.craft = {
|
||||||
@@ -270,9 +120,6 @@ Gallery.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: GallerySettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface HeroProps {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
HeroSimple.craft = {
|
HeroSimple.craft = {
|
||||||
@@ -405,9 +195,6 @@ HeroSimple.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: HeroSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
NumberCounter.craft = {
|
NumberCounter.craft = {
|
||||||
@@ -298,9 +105,6 @@ NumberCounter.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: NumberCounterSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface PricingPlan {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
PricingTable.craft = {
|
PricingTable.craft = {
|
||||||
@@ -397,9 +213,6 @@ PricingTable.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: PricingTableSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface TabItem {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Tabs.craft = {
|
Tabs.craft = {
|
||||||
@@ -289,9 +120,6 @@ Tabs.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TabsSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface Testimonial {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Testimonials.craft = {
|
Testimonials.craft = {
|
||||||
@@ -370,9 +166,6 @@ Testimonials.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TestimonialsSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
export type CtaVariant = 'primary' | 'outline' | 'ghost';
|
export type CtaVariant = 'primary' | 'outline' | 'ghost';
|
||||||
@@ -86,115 +86,3 @@ export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): strin
|
|||||||
}).join('');
|
}).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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user