Add Craft.js site builder (v2) - complete rebuild from GrapesJS
Rebuilt the visual site builder from scratch using Craft.js, React 18, and TypeScript. The new editor renders directly in the DOM (no iframe), supports 40+ components, multi-page with shared header/footer, 16 templates, full-spectrum color/gradient controls, custom head code injection, save/publish workflow, and auto-save. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
craft/src/App.tsx
Normal file
26
craft/src/App.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Editor } from '@craftjs/core';
|
||||
import { EditorShell } from './editor/EditorShell';
|
||||
import { componentResolver } from './components/resolver';
|
||||
import { WhpConfig } from './types';
|
||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||
import { SiteDesignProvider } from './state/SiteDesignContext';
|
||||
import { PageProvider } from './state/PageContext';
|
||||
|
||||
interface AppProps {
|
||||
whpConfig: WhpConfig | null;
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true}>
|
||||
<PageProvider>
|
||||
<EditorShell />
|
||||
</PageProvider>
|
||||
</Editor>
|
||||
</SiteDesignProvider>
|
||||
</EditorConfigProvider>
|
||||
);
|
||||
};
|
||||
232
craft/src/components/basic/ButtonLink.tsx
Normal file
232
craft/src/components/basic/ButtonLink.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface ButtonLinkProps {
|
||||
text?: string;
|
||||
href?: string;
|
||||
target?: '_self' | '_blank';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
text = 'Click Me',
|
||||
href = '#',
|
||||
target = '_self',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
href={href}
|
||||
target={target}
|
||||
onClick={(e) => {
|
||||
// Prevent navigation inside editor
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const ButtonLinkSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ButtonLinkProps,
|
||||
}));
|
||||
|
||||
const colorPresets = [
|
||||
{ bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
|
||||
{ bg: '#10b981', color: '#ffffff', label: 'Green' },
|
||||
{ bg: '#ef4444', color: '#ffffff', label: 'Red' },
|
||||
{ bg: '#f59e0b', color: '#18181b', label: 'Amber' },
|
||||
{ bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
|
||||
{ bg: '#18181b', color: '#ffffff', label: 'Dark' },
|
||||
{ bg: '#ffffff', color: '#18181b', label: 'White' },
|
||||
{ bg: 'transparent', color: '#3b82f6', label: 'Ghost' },
|
||||
];
|
||||
const radiusPresets = ['0px', '4px', '8px', '12px', '9999px'];
|
||||
const paddingPresets = ['8px 16px', '10px 20px', '12px 24px', '14px 32px', '16px 40px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: ButtonLinkProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.href || ''}
|
||||
onChange={(e) => setProp((p: ButtonLinkProps) => { p.href = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Target</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['_self', '_blank'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProp((p: ButtonLinkProps) => { p.target = t; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.target === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{t === '_self' ? 'Same Tab' : 'New Tab'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setProp((p: ButtonLinkProps) => {
|
||||
p.style = {
|
||||
...p.style,
|
||||
backgroundColor: preset.bg,
|
||||
color: preset.color,
|
||||
border: preset.bg === 'transparent' ? `1px solid ${preset.color}` : 'none',
|
||||
};
|
||||
})}
|
||||
title={preset.label}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4,
|
||||
border: preset.bg === 'transparent' ? `2px solid ${preset.color}` : '1px solid #3f3f46',
|
||||
backgroundColor: preset.bg, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, borderRadius: r }; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{paddingPresets.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProp((pr: ButtonLinkProps) => { pr.style = { ...pr.style, padding: p }; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Font Size</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 16px"
|
||||
value={(props.style?.fontSize as string) || ''}
|
||||
onChange={(e) => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, fontSize: e.target.value }; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ButtonLink.craft = {
|
||||
displayName: 'Button',
|
||||
props: {
|
||||
text: 'Click Me',
|
||||
href: '#',
|
||||
target: '_self',
|
||||
style: {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ButtonLinkSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
return {
|
||||
html: `<a href="${props.href || '#'}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
||||
};
|
||||
};
|
||||
119
craft/src/components/basic/Divider.tsx
Normal file
119
craft/src/components/basic/Divider.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface DividerProps {
|
||||
color?: string;
|
||||
thickness?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Divider: UserComponent<DividerProps> = ({
|
||||
color = '#e4e4e7',
|
||||
thickness = '1px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<hr
|
||||
ref={(ref: HTMLHRElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderTop: `${thickness} solid ${color}`,
|
||||
margin: '16px 0',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const DividerSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as DividerProps,
|
||||
}));
|
||||
|
||||
const colorPresets = ['#e4e4e7', '#d4d4d8', '#a1a1aa', '#3f3f46', '#18181b', '#3b82f6', '#ef4444', '#10b981'];
|
||||
const thicknessPresets = ['1px', '2px', '3px', '4px', '6px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: DividerProps) => { p.color = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.color === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thickness</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{thicknessPresets.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProp((p: DividerProps) => { p.thickness = t; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.thickness === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Divider.craft = {
|
||||
displayName: 'Divider',
|
||||
props: {
|
||||
color: '#e4e4e7',
|
||||
thickness: '1px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: DividerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Divider as any).toHtml = (props: DividerProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
border: 'none',
|
||||
borderTop: `${props.thickness || '1px'} solid ${props.color || '#e4e4e7'}`,
|
||||
margin: '16px 0',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<hr${styleStr ? ` style="${styleStr}"` : ''} />` };
|
||||
};
|
||||
153
craft/src/components/basic/Footer.tsx
Normal file
153
craft/src/components/basic/Footer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FooterProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Footer: UserComponent<FooterProps> = ({
|
||||
text = '© 2026 MySite. All rights reserved.',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (elRef.current) {
|
||||
const newText = elRef.current.innerText;
|
||||
setProp((p: FooterProps) => { p.text = newText; }, 500);
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected) {
|
||||
elRef.current.innerText = text || '';
|
||||
}
|
||||
}, [text, selected]);
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={(ref: HTMLElement | null): void => {
|
||||
elRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
contentEditable={selected}
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleBlur}
|
||||
style={{
|
||||
padding: '24px 20px',
|
||||
textAlign: 'center',
|
||||
outline: 'none',
|
||||
cursor: selected ? 'text' : 'pointer',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{selected ? undefined : (text || '')}
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FooterSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FooterProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#18181b', '#0f172a', '#1e293b'];
|
||||
const colorPresets = ['#18181b', '#3f3f46', '#71717a', '#a1a1aa', '#e4e4e7', '#ffffff'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Footer Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: FooterProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, color: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.color === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Footer.craft = {
|
||||
displayName: 'Footer',
|
||||
props: {
|
||||
text: '© 2026 MySite. All rights reserved.',
|
||||
style: {
|
||||
backgroundColor: '#18181b',
|
||||
color: '#a1a1aa',
|
||||
fontSize: '14px',
|
||||
padding: '24px 20px',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FooterSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Footer as any).toHtml = (props: FooterProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '24px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
|
||||
};
|
||||
181
craft/src/components/basic/Heading.tsx
Normal file
181
craft/src/components/basic/Heading.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { TypographyControl } from '../../ui/TypographyControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
|
||||
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
|
||||
interface HeadingProps {
|
||||
text?: string;
|
||||
level?: HeadingLevel;
|
||||
style?: CSSProperties;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
export const Heading: UserComponent<HeadingProps> = ({
|
||||
text = 'Heading',
|
||||
level = 'h2',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
const editedTextRef = useRef<string | null>(null);
|
||||
|
||||
const commitText = useCallback(() => {
|
||||
if (elRef.current) {
|
||||
const newText = elRef.current.innerText;
|
||||
editedTextRef.current = newText;
|
||||
setProp((p: HeadingProps) => { p.text = newText; });
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
// Commit on blur
|
||||
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
|
||||
|
||||
// Also commit on deselect via effect
|
||||
useEffect(() => {
|
||||
if (!selected && editedTextRef.current !== null) {
|
||||
setProp((p: HeadingProps) => { p.text = editedTextRef.current!; });
|
||||
editedTextRef.current = null;
|
||||
}
|
||||
}, [selected, setProp]);
|
||||
|
||||
// Set DOM text on mount and when text prop changes externally (not during editing)
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected && editedTextRef.current === null) {
|
||||
elRef.current.innerText = text || '';
|
||||
}
|
||||
}, [text, selected]);
|
||||
|
||||
return React.createElement(level, {
|
||||
ref: (ref: HTMLElement | null): void => {
|
||||
elRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
},
|
||||
contentEditable: selected,
|
||||
suppressContentEditableWarning: true,
|
||||
onBlur: handleBlur,
|
||||
onInput: () => {
|
||||
// Track that we have unsaved edits
|
||||
if (elRef.current) {
|
||||
editedTextRef.current = elRef.current.innerText;
|
||||
}
|
||||
},
|
||||
style: { outline: 'none', cursor: selected ? 'text' : 'pointer', minHeight: '1em', ...style },
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const HeadingSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HeadingProps,
|
||||
}));
|
||||
|
||||
const levels: HeadingLevel[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
||||
|
||||
return (
|
||||
<SettingsTabs
|
||||
general={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Heading Level</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{levels.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setProp((p: HeadingProps) => { p.level = l; })}
|
||||
style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 4, border: '1px solid #3f3f46', cursor: 'pointer',
|
||||
background: props.level === l ? '#3b82f6' : '#27272a', color: props.level === l ? '#fff' : '#a1a1aa',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
}}
|
||||
>{l.toUpperCase()}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: HeadingProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
style={
|
||||
<TypographyControl
|
||||
style={props.style || {}}
|
||||
onChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
/>
|
||||
}
|
||||
advanced={
|
||||
<AdvancedTab
|
||||
style={props.style || {}}
|
||||
onStyleChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
cssId={props.cssId || ''}
|
||||
onCssIdChange={(id) => setProp((p: HeadingProps) => { p.cssId = id; })}
|
||||
cssClass={props.cssClass || ''}
|
||||
onCssClassChange={(cls) => setProp((p: HeadingProps) => { p.cssClass = cls; })}
|
||||
hideOnDesktop={props.hideOnDesktop}
|
||||
onHideOnDesktopChange={(v) => setProp((p: HeadingProps) => { p.hideOnDesktop = v; })}
|
||||
hideOnTablet={props.hideOnTablet}
|
||||
onHideOnTabletChange={(v) => setProp((p: HeadingProps) => { p.hideOnTablet = v; })}
|
||||
hideOnMobile={props.hideOnMobile}
|
||||
onHideOnMobileChange={(v) => setProp((p: HeadingProps) => { p.hideOnMobile = v; })}
|
||||
animation={props.animation}
|
||||
onAnimationChange={(v) => setProp((p: HeadingProps) => { p.animation = v; })}
|
||||
animationDelay={props.animationDelay}
|
||||
onAnimationDelayChange={(v) => setProp((p: HeadingProps) => { p.animationDelay = v; })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Heading.craft = {
|
||||
displayName: 'Heading',
|
||||
props: {
|
||||
text: 'Your Heading',
|
||||
level: 'h2' as HeadingLevel,
|
||||
style: {
|
||||
fontSize: '36px',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#1f2937',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HeadingSettings,
|
||||
},
|
||||
};
|
||||
|
||||
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
|
||||
const tag = props.level || 'h2';
|
||||
const safeText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
|
||||
};
|
||||
127
craft/src/components/basic/HtmlBlock.tsx
Normal file
127
craft/src/components/basic/HtmlBlock.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface HtmlBlockProps {
|
||||
code: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({
|
||||
code = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: code }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const HtmlBlockSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HtmlBlockProps,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div style={{
|
||||
padding: '8px 10px',
|
||||
background: '#44200a',
|
||||
border: '1px solid #92400e',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
color: '#fbbf24',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
This block renders raw HTML. Use with caution.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>HTML Code</label>
|
||||
<textarea
|
||||
value={props.code || ''}
|
||||
onChange={(e) => setProp((p: HtmlBlockProps) => { p.code = e.target.value; })}
|
||||
placeholder="<div>Your HTML here...</div>"
|
||||
rows={16}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: '#1a1a2e',
|
||||
color: '#a5f3fc',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily: '"Source Code Pro", "Fira Code", monospace',
|
||||
lineHeight: 1.5,
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: 'pre',
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Outer container style */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['0px', '8px', '16px', '24px', '32px'].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProp((pr: HtmlBlockProps) => { pr.style = { ...pr.style, padding: p }; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
HtmlBlock.craft = {
|
||||
displayName: 'HTML',
|
||||
props: {
|
||||
code: '',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HtmlBlockSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
|
||||
// Output the raw code as-is
|
||||
return { html: props.code || '' };
|
||||
};
|
||||
325
craft/src/components/basic/Icon.tsx
Normal file
325
craft/src/components/basic/Icon.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface IconProps {
|
||||
icon?: string;
|
||||
size?: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
bgShape?: 'none' | 'circle' | 'square' | 'rounded';
|
||||
bgSize?: string;
|
||||
link?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const COMMON_ICONS = [
|
||||
'fa-star', 'fa-heart', 'fa-check', 'fa-phone', 'fa-envelope',
|
||||
'fa-map-marker', 'fa-globe', 'fa-facebook', 'fa-twitter', 'fa-instagram',
|
||||
'fa-linkedin', 'fa-youtube', 'fa-github', 'fa-arrow-right', 'fa-arrow-down',
|
||||
'fa-play', 'fa-search', 'fa-user', 'fa-lock', 'fa-cog',
|
||||
'fa-home', 'fa-comment', 'fa-camera', 'fa-music', 'fa-shopping-cart',
|
||||
'fa-calendar', 'fa-clock-o', 'fa-thumbs-up', 'fa-lightbulb-o', 'fa-rocket',
|
||||
];
|
||||
|
||||
function getBgBorderRadius(shape: string): string {
|
||||
if (shape === 'circle') return '50%';
|
||||
if (shape === 'rounded') return '8px';
|
||||
if (shape === 'square') return '0px';
|
||||
return '0px';
|
||||
}
|
||||
|
||||
export const Icon: UserComponent<IconProps> = ({
|
||||
icon = 'fa-star',
|
||||
size = '32px',
|
||||
color = '#3b82f6',
|
||||
bgColor = 'transparent',
|
||||
bgShape = 'none',
|
||||
bgSize = '56px',
|
||||
link = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const iconEl = (
|
||||
<i
|
||||
className={`fa ${icon}`}
|
||||
style={{ fontSize: size, color, lineHeight: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
|
||||
|
||||
const wrapperEl = hasBg ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: bgSize,
|
||||
height: bgSize,
|
||||
backgroundColor: bgColor,
|
||||
borderRadius: getBgBorderRadius(bgShape || 'none'),
|
||||
}}
|
||||
>
|
||||
{iconEl}
|
||||
</div>
|
||||
) : iconEl;
|
||||
|
||||
const content = link ? (
|
||||
<a href={link} onClick={(e) => e.preventDefault()} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{wrapperEl}
|
||||
</a>
|
||||
) : wrapperEl;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const IconSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as IconProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const sizePresets = ['24px', '32px', '48px', '64px'];
|
||||
const colorPresets = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#18181b', '#ffffff'];
|
||||
const shapePresets: Array<{ label: string; value: IconProps['bgShape'] }> = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Circle', value: 'circle' },
|
||||
{ label: 'Square', value: 'square' },
|
||||
{ label: 'Rounded', value: 'rounded' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Icon picker */}
|
||||
<div>
|
||||
<label style={labelStyle}>Icon</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
|
||||
{COMMON_ICONS.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
onClick={() => setProp((p: IconProps) => { p.icon = ic; })}
|
||||
title={ic}
|
||||
style={{
|
||||
padding: '6px', fontSize: 16, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.icon === ic ? '#3b82f6' : '#27272a',
|
||||
color: props.icon === ic ? '#fff' : '#e4e4e7',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${ic}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom icon class */}
|
||||
<div>
|
||||
<label style={labelStyle}>Custom Icon Class</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.icon || ''}
|
||||
onChange={(e) => setProp((p: IconProps) => { p.icon = e.target.value; })}
|
||||
placeholder="fa-star"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<div>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: IconProps) => { p.size = s; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.size === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: IconProps) => { p.color = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.color === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background shape */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background Shape</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{shapePresets.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setProp((p: IconProps) => { p.bgShape = s.value; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.bgShape === s.value ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background color */}
|
||||
{props.bgShape !== 'none' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#18181b', '#f1f5f9', '#ffffff'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: IconProps) => { p.bgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background size */}
|
||||
{props.bgShape !== 'none' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.bgSize || '56px'}
|
||||
onChange={(e) => setProp((p: IconProps) => { p.bgSize = e.target.value; })}
|
||||
placeholder="56px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.link || ''}
|
||||
onChange={(e) => setProp((p: IconProps) => { p.link = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Icon.craft = {
|
||||
displayName: 'Icon',
|
||||
props: {
|
||||
icon: 'fa-star',
|
||||
size: '32px',
|
||||
color: '#3b82f6',
|
||||
bgColor: 'transparent',
|
||||
bgShape: 'none',
|
||||
bgSize: '56px',
|
||||
link: '',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: IconSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Icon as any).toHtml = (props: IconProps, _childrenHtml: string) => {
|
||||
const {
|
||||
icon = 'fa-star',
|
||||
size = '32px',
|
||||
color = '#3b82f6',
|
||||
bgColor = 'transparent',
|
||||
bgShape = 'none',
|
||||
bgSize = '56px',
|
||||
link = '',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
|
||||
let iconHtml = `<i class="fa ${icon}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
|
||||
|
||||
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
|
||||
if (hasBg) {
|
||||
const bgStyle = cssPropsToString({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: bgSize,
|
||||
height: bgSize,
|
||||
backgroundColor: bgColor,
|
||||
borderRadius: getBgBorderRadius(bgShape || 'none'),
|
||||
});
|
||||
iconHtml = `<div${bgStyle ? ` style="${bgStyle}"` : ''}>${iconHtml}</div>`;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
iconHtml = `<a href="${link}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
|
||||
}
|
||||
|
||||
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
|
||||
return { html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${iconHtml}</div>` };
|
||||
};
|
||||
418
craft/src/components/basic/Logo.tsx
Normal file
418
craft/src/components/basic/Logo.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface LogoProps {
|
||||
type?: 'text' | 'image';
|
||||
text?: string;
|
||||
imageSrc?: string;
|
||||
imageWidth?: string;
|
||||
href?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
color?: string;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ---------- Helper: escape HTML ---------- */
|
||||
function esc(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Logo: UserComponent<LogoProps> = ({
|
||||
type = 'text',
|
||||
text = 'MySite',
|
||||
imageSrc = '',
|
||||
imageWidth = '120px',
|
||||
href = '/',
|
||||
fontFamily = 'Inter, sans-serif',
|
||||
fontSize = '20px',
|
||||
fontWeight = '700',
|
||||
color,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
} = useNode();
|
||||
|
||||
const { design } = useSiteDesign();
|
||||
const resolvedColor = color || design.textColor;
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
href={href}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{type === 'image' && imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={text || 'Logo'}
|
||||
style={{ width: imageWidth, height: 'auto', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
color: resolvedColor,
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const LogoSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as LogoProps,
|
||||
}));
|
||||
|
||||
const { design } = useSiteDesign();
|
||||
const logoType = props.type || 'text';
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
|
||||
const fontFamilies = [
|
||||
{ label: 'Inter', value: 'Inter, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ label: 'Poppins', value: 'Poppins, sans-serif' },
|
||||
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
|
||||
{ label: 'Playfair', value: 'Playfair Display, serif' },
|
||||
{ label: 'Merriweather', value: 'Merriweather, serif' },
|
||||
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
|
||||
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
|
||||
];
|
||||
|
||||
const handleLogoUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: LogoProps) => { p.imageSrc = url; });
|
||||
}, [setProp]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
/* ---- Shared styles ---- */
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
const btnSmall: CSSProperties = {
|
||||
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
};
|
||||
const btnActive: CSSProperties = {
|
||||
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Type toggle */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.type = 'text'; })}
|
||||
style={logoType === 'text' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.type = 'image'; })}
|
||||
style={logoType === 'image' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logoType === 'text' ? (
|
||||
<>
|
||||
<div>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.text = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Font Family</label>
|
||||
<select
|
||||
value={props.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.fontFamily = e.target.value; })}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
{fontFamilies.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.fontSize || '20px'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
|
||||
placeholder="20px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Weight</label>
|
||||
<select
|
||||
value={props.fontWeight || '700'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.fontWeight = e.target.value; })}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="300">Light</option>
|
||||
<option value="400">Normal</option>
|
||||
<option value="500">Medium</option>
|
||||
<option value="600">Semi</option>
|
||||
<option value="700">Bold</option>
|
||||
<option value="800">Extra Bold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.color || design.textColor}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.color = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>{props.color || 'Auto'}</span>
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.color = undefined; })}
|
||||
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
|
||||
title="Reset to auto"
|
||||
>Auto</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Image logo controls */}
|
||||
{props.imageSrc ? (
|
||||
<div style={{ borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={props.imageSrc} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.imageSrc = ''; })}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
|
||||
Drop logo or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Browse grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map(asset => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => { setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL paste input */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={props.imageSrc || ''}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
|
||||
placeholder="Or paste image URL..."
|
||||
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Logo Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.imageWidth || '120px'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
|
||||
placeholder="120px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Link URL */}
|
||||
<div>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.href || '/'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.href = e.target.value; })}
|
||||
placeholder="/"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Logo.craft = {
|
||||
displayName: 'Logo',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: 'MySite',
|
||||
imageSrc: '',
|
||||
imageWidth: '120px',
|
||||
href: '/',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: undefined,
|
||||
style: {},
|
||||
} as LogoProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: LogoSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Logo as any).toHtml = (props: LogoProps, _childrenHtml: string) => {
|
||||
const href = props.href || '/';
|
||||
|
||||
let innerHtml: string;
|
||||
if (props.type === 'image' && props.imageSrc) {
|
||||
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
|
||||
innerHtml = `<img src="${esc(props.imageSrc)}" alt="${esc(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
||||
} else {
|
||||
const spanStyle = cssPropsToString({
|
||||
fontWeight: props.fontWeight || '700',
|
||||
fontSize: props.fontSize || '20px',
|
||||
fontFamily: props.fontFamily || 'Inter, sans-serif',
|
||||
color: props.color || '#1f2937',
|
||||
});
|
||||
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${esc(props.text || 'MySite')}</span>`;
|
||||
}
|
||||
|
||||
const aStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: '0',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<a href="${esc(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||
};
|
||||
};
|
||||
510
craft/src/components/basic/Menu.tsx
Normal file
510
craft/src/components/basic/Menu.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface MenuLink {
|
||||
text: string;
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
links?: MenuLink[];
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
linkColor?: string;
|
||||
linkHoverColor?: string;
|
||||
ctaBgColor?: string;
|
||||
ctaTextColor?: string;
|
||||
gap?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
fontSize?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
|
||||
const defaultLinks: MenuLink[] = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '#about' },
|
||||
{ text: 'Services', href: '#services' },
|
||||
{ text: 'Contact', href: '#contact', isCta: true },
|
||||
];
|
||||
|
||||
/* ---------- Helper: escape HTML ---------- */
|
||||
function esc(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Menu: UserComponent<MenuProps> = ({
|
||||
links = defaultLinks,
|
||||
alignment = 'right',
|
||||
linkColor = '#3f3f46',
|
||||
linkHoverColor = '#3b82f6',
|
||||
ctaBgColor = '#3b82f6',
|
||||
ctaTextColor = '#ffffff',
|
||||
gap = '24px',
|
||||
orientation = 'horizontal',
|
||||
fontSize = '14px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
} = useNode();
|
||||
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
|
||||
const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: orientation === 'vertical' ? 'column' : 'row',
|
||||
alignItems: orientation === 'vertical' ? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start') : 'center',
|
||||
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
|
||||
gap,
|
||||
flexWrap: 'wrap',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
fontSize,
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta
|
||||
? ctaTextColor
|
||||
: (hoveredLink === i ? linkHoverColor : linkColor),
|
||||
backgroundColor: link.isCta ? ctaBgColor : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
|
||||
}}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const MenuSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as MenuProps,
|
||||
}));
|
||||
|
||||
const { pages } = usePages();
|
||||
const links = props.links || defaultLinks;
|
||||
|
||||
/* Drag state for reordering */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
/* ---- Link management ---- */
|
||||
|
||||
const updateLink = (index: number, field: keyof MenuLink, value: string | boolean) => {
|
||||
setProp((p: MenuProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = (link?: Partial<MenuLink>) => {
|
||||
setProp((p: MenuProps) => {
|
||||
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
setProp((p: MenuProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated.splice(index, 1);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const moveLink = (fromIdx: number, toIdx: number) => {
|
||||
if (fromIdx === toIdx) return;
|
||||
setProp((p: MenuProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
const [moved] = updated.splice(fromIdx, 1);
|
||||
updated.splice(toIdx, 0, moved);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Shared styles ---- */
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
const sectionStyle: CSSProperties = {
|
||||
borderBottom: '1px solid #27272a', paddingBottom: 12,
|
||||
};
|
||||
const btnSmall: CSSProperties = {
|
||||
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
};
|
||||
const btnActive: CSSProperties = {
|
||||
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
||||
};
|
||||
|
||||
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
|
||||
const gapPresets = ['8px', '16px', '24px', '32px', '40px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
|
||||
{/* ===== Style Section ===== */}
|
||||
<div style={sectionStyle}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Menu Style</label>
|
||||
|
||||
{/* Link color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Link Color</label>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{textColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: MenuProps) => { p.linkColor = c; })}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.linkColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={props.linkColor || '#3f3f46'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.linkColor = e.target.value; })}
|
||||
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Hover Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.linkHoverColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.linkHoverColor = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>{props.linkHoverColor || '#3b82f6'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA button colors */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>CTA Button</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaBgColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.ctaBgColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaTextColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.ctaTextColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font size */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Font Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.fontSize || '14px'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.fontSize = e.target.value; })}
|
||||
placeholder="14px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setProp((p: MenuProps) => { p.alignment = a; })}
|
||||
style={(props.alignment || 'right') === a ? btnActive : btnSmall}
|
||||
>
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orientation */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Orientation</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['horizontal', 'vertical'] as const).map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => setProp((p: MenuProps) => { p.orientation = o; })}
|
||||
style={(props.orientation || 'horizontal') === o ? btnActive : btnSmall}
|
||||
>
|
||||
{o.charAt(0).toUpperCase() + o.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap */}
|
||||
<div>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gapPresets.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: MenuProps) => { p.gap = g; })}
|
||||
style={(props.gap || '24px') === g ? btnActive : btnSmall}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Links Section ===== */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={() => setDragIdx(i)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
|
||||
onDragEnd={() => {
|
||||
if (dragIdx !== null && dragOverIdx !== null) {
|
||||
moveLink(dragIdx, dragOverIdx);
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}}
|
||||
style={{
|
||||
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
|
||||
transition: 'background 0.1s, border-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Row 1: drag handle + text + delete */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span
|
||||
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<i className="fa fa-bars" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={link.text}
|
||||
onChange={(e) => updateLink(i, 'text', e.target.value)}
|
||||
placeholder="Text"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
|
||||
title="Delete link"
|
||||
>
|
||||
<i className="fa fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: URL */}
|
||||
<input
|
||||
type="text"
|
||||
value={link.href}
|
||||
onChange={(e) => updateLink(i, 'href', e.target.value)}
|
||||
placeholder="URL (e.g. /about or https://...)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Row 3: checkboxes */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
|
||||
External
|
||||
</label>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
|
||||
CTA
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add link button */}
|
||||
<button
|
||||
onClick={() => addLink()}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Link
|
||||
</button>
|
||||
|
||||
{/* Add page dropdown */}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const page = pages.find(p => p.id === e.target.value);
|
||||
if (page) {
|
||||
addLink({
|
||||
text: page.name,
|
||||
href: page.slug === 'index' ? '/' : page.slug,
|
||||
isExternal: false,
|
||||
isCta: false,
|
||||
});
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
value=""
|
||||
style={{
|
||||
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
|
||||
background: '#1e293b', color: '#93c5fd',
|
||||
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="">+ Add Page...</option>
|
||||
{pages.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.slug === 'index' ? '/' : p.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Menu.craft = {
|
||||
displayName: 'Menu',
|
||||
props: {
|
||||
links: defaultLinks,
|
||||
alignment: 'right',
|
||||
linkColor: '#3f3f46',
|
||||
linkHoverColor: '#3b82f6',
|
||||
ctaBgColor: '#3b82f6',
|
||||
ctaTextColor: '#ffffff',
|
||||
gap: '24px',
|
||||
orientation: 'horizontal',
|
||||
fontSize: '14px',
|
||||
style: {},
|
||||
} as MenuProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: MenuSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => {
|
||||
const linkCol = props.linkColor || '#3f3f46';
|
||||
const hoverCol = props.linkHoverColor || '#3b82f6';
|
||||
const ctaBg = props.ctaBgColor || '#3b82f6';
|
||||
const ctaText = props.ctaTextColor || '#ffffff';
|
||||
const gap = props.gap || '24px';
|
||||
const orientation = props.orientation || 'horizontal';
|
||||
const alignment = props.alignment || 'right';
|
||||
const fSize = props.fontSize || '14px';
|
||||
|
||||
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||
|
||||
const navStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: orientation === 'vertical' ? 'column' : 'row',
|
||||
alignItems: orientation === 'vertical'
|
||||
? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start')
|
||||
: 'center',
|
||||
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
|
||||
gap,
|
||||
flexWrap: 'wrap',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
const links = props.links || defaultLinks;
|
||||
|
||||
// Unique ID suffix for scoped CSS
|
||||
const scopeId = `menu-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const cls = link.isCta ? `${scopeId}-cta` : `${scopeId}-link`;
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: fSize,
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta ? ctaText : linkCol,
|
||||
backgroundColor: link.isCta ? ctaBg : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
const hoverCss = `<style>
|
||||
.${scopeId}-link:hover { color: ${hoverCol} !important; }
|
||||
.${scopeId}-cta:hover { filter: brightness(1.1); }
|
||||
</style>`;
|
||||
|
||||
return {
|
||||
html: `${hoverCss}
|
||||
<nav${navStyle ? ` style="${navStyle}"` : ''}>
|
||||
${linksHtml}
|
||||
</nav>`,
|
||||
};
|
||||
};
|
||||
929
craft/src/components/basic/Navbar.tsx
Normal file
929
craft/src/components/basic/Navbar.tsx
Normal file
@@ -0,0 +1,929 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface NavLink {
|
||||
text: string;
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
logoType?: 'text' | 'image';
|
||||
logoText?: string;
|
||||
logoImage?: string;
|
||||
logoWidth?: string;
|
||||
logoUrl?: string;
|
||||
logoFontFamily?: string;
|
||||
logoFontSize?: string;
|
||||
logoColor?: string;
|
||||
links?: NavLink[];
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
hoverColor?: string;
|
||||
ctaColor?: string;
|
||||
ctaTextColor?: string;
|
||||
padding?: string;
|
||||
navAlignment?: 'left' | 'center' | 'right' | 'space-between';
|
||||
isSticky?: boolean;
|
||||
showMobileMenu?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
|
||||
const defaultLinks: NavLink[] = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '#about' },
|
||||
{ text: 'Services', href: '#services' },
|
||||
{ text: 'Contact', href: '#contact', isCta: true },
|
||||
];
|
||||
|
||||
const PADDING_PRESETS = [
|
||||
{ label: 'Compact', value: '8px 16px' },
|
||||
{ label: 'Normal', value: '16px 24px' },
|
||||
{ label: 'Relaxed', value: '20px 32px' },
|
||||
{ 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; }
|
||||
}
|
||||
|
||||
/* ---------- Helper: escape HTML ---------- */
|
||||
function esc(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Navbar: UserComponent<NavbarProps> = ({
|
||||
logoType = 'text',
|
||||
logoText = 'MySite',
|
||||
logoImage = '',
|
||||
logoWidth = '120px',
|
||||
logoUrl = '/',
|
||||
logoFontFamily = 'Inter, sans-serif',
|
||||
logoFontSize = '20px',
|
||||
logoColor,
|
||||
links = defaultLinks,
|
||||
backgroundColor = '#ffffff',
|
||||
textColor = '#3f3f46',
|
||||
hoverColor = '#3b82f6',
|
||||
ctaColor = '#3b82f6',
|
||||
ctaTextColor = '#ffffff',
|
||||
padding = '16px 24px',
|
||||
navAlignment = 'space-between',
|
||||
isSticky = false,
|
||||
showMobileMenu = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const { design } = useSiteDesign();
|
||||
const resolvedLogoColor = logoColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? design.textColor : '#ffffff');
|
||||
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
|
||||
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: navAlignment,
|
||||
padding,
|
||||
backgroundColor,
|
||||
...(isSticky ? { position: 'sticky' as const, top: 0, zIndex: 1000 } : {}),
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<a
|
||||
href={logoUrl}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}
|
||||
>
|
||||
{logoType === 'image' && logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
alt={logoText || 'Logo'}
|
||||
style={{ width: logoWidth, height: 'auto', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{
|
||||
fontWeight: '700',
|
||||
fontSize: logoFontSize,
|
||||
fontFamily: logoFontFamily,
|
||||
color: resolvedLogoColor,
|
||||
}}>
|
||||
{logoText}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Links */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||
{showMobileMenu && (
|
||||
<div
|
||||
style={{
|
||||
display: 'none', /* Hidden in editor, shown via media query in export */
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
}}
|
||||
className="navbar-hamburger"
|
||||
>
|
||||
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
|
||||
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
|
||||
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
|
||||
</div>
|
||||
)}
|
||||
{links.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta
|
||||
? ctaTextColor
|
||||
: (hoveredLink === i ? hoverColor : resolvedTextColor),
|
||||
backgroundColor: link.isCta ? ctaColor : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
|
||||
}}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const NavbarSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as NavbarProps,
|
||||
}));
|
||||
|
||||
const { pages } = usePages();
|
||||
const { design } = useSiteDesign();
|
||||
|
||||
const links = props.links || defaultLinks;
|
||||
const logoType = props.logoType || 'text';
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
|
||||
/* Drag state for reordering */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f9fafb', '#18181b', '#0f172a', '#1e293b', '#1f2937', '#111827'];
|
||||
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
|
||||
const fontFamilies = [
|
||||
{ label: 'Inter', value: 'Inter, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ label: 'Poppins', value: 'Poppins, sans-serif' },
|
||||
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
|
||||
{ label: 'Playfair', value: 'Playfair Display, serif' },
|
||||
{ label: 'Merriweather', value: 'Merriweather, serif' },
|
||||
];
|
||||
|
||||
/* ---- Link management ---- */
|
||||
|
||||
const updateLink = (index: number, field: keyof NavLink, value: string | boolean) => {
|
||||
setProp((p: NavbarProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = (link?: Partial<NavLink>) => {
|
||||
setProp((p: NavbarProps) => {
|
||||
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
setProp((p: NavbarProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated.splice(index, 1);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const moveLink = (fromIdx: number, toIdx: number) => {
|
||||
if (fromIdx === toIdx) return;
|
||||
setProp((p: NavbarProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
const [moved] = updated.splice(fromIdx, 1);
|
||||
updated.splice(toIdx, 0, moved);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Image upload for logo ---- */
|
||||
|
||||
const handleLogoUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: NavbarProps) => { p.logoImage = url; });
|
||||
}, [setProp]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
/* ---- Shared styles ---- */
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
const sectionStyle: CSSProperties = {
|
||||
borderBottom: '1px solid #27272a', paddingBottom: 12,
|
||||
};
|
||||
const swatchStyle = (color: string, active: boolean): CSSProperties => ({
|
||||
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: color, cursor: 'pointer',
|
||||
outline: active ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
});
|
||||
const btnSmall: CSSProperties = {
|
||||
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
};
|
||||
const btnActive: CSSProperties = {
|
||||
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
|
||||
{/* ===== Logo Section ===== */}
|
||||
<div style={sectionStyle}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo</label>
|
||||
|
||||
{/* Logo type toggle */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'text'; })}
|
||||
style={logoType === 'text' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'image'; })}
|
||||
style={logoType === 'image' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{logoType === 'text' ? (
|
||||
<>
|
||||
{/* Text logo controls */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoText || ''}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoText = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<label style={labelStyle}>Font Family</label>
|
||||
<select
|
||||
value={props.logoFontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontFamily = e.target.value; })}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
{fontFamilies.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoFontSize || '20px'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontSize = e.target.value; })}
|
||||
placeholder="20px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.logoColor || design.textColor}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoColor = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoColor = undefined; })}
|
||||
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
|
||||
title="Reset to auto"
|
||||
>Auto</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Image logo controls */}
|
||||
{props.logoImage ? (
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={props.logoImage} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoImage = ''; })}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer', marginBottom: 8 }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
|
||||
Drop logo or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Browse grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginBottom: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map(asset => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => { setProp((p: NavbarProps) => { p.logoImage = asset.url; }); setShowBrowser(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoImage || ''}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoImage = e.target.value; })}
|
||||
placeholder="Or paste image URL..."
|
||||
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Logo Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoWidth || '120px'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoWidth = e.target.value; })}
|
||||
placeholder="120px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logo link URL (shared) */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<label style={labelStyle}>Logo Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoUrl || '/'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoUrl = e.target.value; })}
|
||||
placeholder="/"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Nav Style Section ===== */}
|
||||
<div style={sectionStyle}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Nav Style</label>
|
||||
|
||||
{/* Background color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NavbarProps) => { p.backgroundColor = c; })}
|
||||
style={swatchStyle(c, props.backgroundColor === c)}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={props.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.backgroundColor = e.target.value; })}
|
||||
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{textColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NavbarProps) => { p.textColor = c; })}
|
||||
style={swatchStyle(c, props.textColor === c)}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={props.textColor || '#3f3f46'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.textColor = e.target.value; })}
|
||||
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link hover color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Hover Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.hoverColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.hoverColor = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>{props.hoverColor || '#3b82f6'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA button colors */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>CTA Button</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.ctaColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaTextColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.ctaTextColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Padding presets */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Padding</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{PADDING_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => setProp((pr: NavbarProps) => { pr.padding = p.value; })}
|
||||
style={props.padding === p.value ? btnActive : btnSmall}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right', 'space-between'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setProp((p: NavbarProps) => { p.navAlignment = a; })}
|
||||
style={props.navAlignment === a || (!props.navAlignment && a === 'space-between') ? btnActive : btnSmall}
|
||||
>
|
||||
{a === 'space-between' ? 'Spread' : a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky toggle */}
|
||||
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.isSticky}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.isSticky = e.target.checked; })}
|
||||
/>
|
||||
Sticky
|
||||
</label>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.showMobileMenu}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.showMobileMenu = e.target.checked; })}
|
||||
/>
|
||||
Mobile Menu
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Design token quick apply */}
|
||||
<div>
|
||||
<label style={labelStyle}>Apply Design Token</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => {
|
||||
p.backgroundColor = '#ffffff';
|
||||
p.textColor = design.textColor;
|
||||
p.hoverColor = design.primaryColor;
|
||||
p.ctaColor = design.primaryColor;
|
||||
p.ctaTextColor = '#ffffff';
|
||||
})}
|
||||
style={btnSmall}
|
||||
>
|
||||
<i className="fa fa-sun-o" style={{ marginRight: 3 }} />Light
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => {
|
||||
p.backgroundColor = '#0f172a';
|
||||
p.textColor = '#e4e4e7';
|
||||
p.hoverColor = design.primaryColor;
|
||||
p.ctaColor = design.primaryColor;
|
||||
p.ctaTextColor = '#ffffff';
|
||||
p.logoColor = '#ffffff';
|
||||
})}
|
||||
style={btnSmall}
|
||||
>
|
||||
<i className="fa fa-moon-o" style={{ marginRight: 3 }} />Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Links Section ===== */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={() => setDragIdx(i)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
|
||||
onDragEnd={() => {
|
||||
if (dragIdx !== null && dragOverIdx !== null) {
|
||||
moveLink(dragIdx, dragOverIdx);
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}}
|
||||
style={{
|
||||
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
|
||||
transition: 'background 0.1s, border-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Row 1: drag handle + text + delete */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span
|
||||
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<i className="fa fa-bars" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={link.text}
|
||||
onChange={(e) => updateLink(i, 'text', e.target.value)}
|
||||
placeholder="Text"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
|
||||
title="Delete link"
|
||||
>
|
||||
<i className="fa fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: URL */}
|
||||
<input
|
||||
type="text"
|
||||
value={link.href}
|
||||
onChange={(e) => updateLink(i, 'href', e.target.value)}
|
||||
placeholder="URL (e.g. /about or https://...)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Row 3: checkboxes */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
|
||||
External
|
||||
</label>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
|
||||
CTA
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add link button */}
|
||||
<button
|
||||
onClick={() => addLink()}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Link
|
||||
</button>
|
||||
|
||||
{/* Add page dropdown */}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const page = pages.find(p => p.id === e.target.value);
|
||||
if (page) {
|
||||
addLink({
|
||||
text: page.name,
|
||||
href: page.slug === 'index' ? '/' : page.slug,
|
||||
isExternal: false,
|
||||
isCta: false,
|
||||
});
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
value=""
|
||||
style={{
|
||||
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
|
||||
background: '#1e293b', color: '#93c5fd',
|
||||
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="">+ Add Page...</option>
|
||||
{pages.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.slug === 'index' ? '/' : p.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Navbar.craft = {
|
||||
displayName: 'Navbar',
|
||||
props: {
|
||||
logoType: 'text',
|
||||
logoText: 'MySite',
|
||||
logoImage: '',
|
||||
logoWidth: '120px',
|
||||
logoUrl: '/',
|
||||
logoFontFamily: 'Inter, sans-serif',
|
||||
logoFontSize: '20px',
|
||||
logoColor: undefined,
|
||||
links: defaultLinks,
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#3f3f46',
|
||||
hoverColor: '#3b82f6',
|
||||
ctaColor: '#3b82f6',
|
||||
ctaTextColor: '#ffffff',
|
||||
padding: '16px 24px',
|
||||
navAlignment: 'space-between',
|
||||
isSticky: false,
|
||||
showMobileMenu: false,
|
||||
style: {
|
||||
borderBottom: '1px solid #e4e4e7',
|
||||
},
|
||||
} as NavbarProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: NavbarSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
|
||||
const bgColor = props.backgroundColor || '#ffffff';
|
||||
const textCol = props.textColor || '#3f3f46';
|
||||
const hoverCol = props.hoverColor || '#3b82f6';
|
||||
const ctaCol = props.ctaColor || '#3b82f6';
|
||||
const ctaTextCol = props.ctaTextColor || '#ffffff';
|
||||
const pad = props.padding || '16px 24px';
|
||||
const alignment = props.navAlignment || 'space-between';
|
||||
const sticky = props.isSticky;
|
||||
const mobile = props.showMobileMenu;
|
||||
const logoUrl = props.logoUrl || '/';
|
||||
|
||||
const navStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: alignment,
|
||||
padding: pad,
|
||||
backgroundColor: bgColor,
|
||||
...(sticky ? { position: 'sticky', top: '0', zIndex: '1000' } : {}),
|
||||
...props.style,
|
||||
});
|
||||
|
||||
// Logo HTML
|
||||
let logoHtml: string;
|
||||
if (props.logoType === 'image' && props.logoImage) {
|
||||
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
|
||||
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${esc(props.logoImage)}" alt="${esc(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
||||
} else {
|
||||
const logoStyle = cssPropsToString({
|
||||
fontWeight: '700',
|
||||
fontSize: props.logoFontSize || '20px',
|
||||
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
|
||||
color: props.logoColor || textCol,
|
||||
});
|
||||
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${esc(props.logoText || 'MySite')}</span></a>`;
|
||||
}
|
||||
|
||||
// Links HTML
|
||||
const links = props.links || defaultLinks;
|
||||
const linksHtml = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta ? ctaTextCol : textCol,
|
||||
backgroundColor: link.isCta ? ctaCol : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${esc(link.href)}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
// Hamburger HTML for mobile
|
||||
const hamburgerHtml = mobile
|
||||
? `\n <button class="navbar-hamburger" onclick="this.parentElement.querySelector('.navbar-links').classList.toggle('navbar-open')" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
|
||||
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
// Hover CSS
|
||||
const hoverCss = `<style>
|
||||
.navbar-link:hover { color: ${hoverCol} !important; }
|
||||
.navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
|
||||
@media (max-width: 768px) {
|
||||
.navbar-hamburger { display: flex !important; }
|
||||
.navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.navbar-links.navbar-open { display: flex !important; }
|
||||
}` : ''}
|
||||
</style>`;
|
||||
|
||||
// Add CSS class to each link for hover
|
||||
const linksHtmlWithClass = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta ? ctaTextCol : textCol,
|
||||
backgroundColor: link.isCta ? ctaCol : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `${hoverCss}
|
||||
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
|
||||
${logoHtml}${hamburgerHtml}
|
||||
<div class="navbar-links" style="display:flex;align-items:center;gap:24px">
|
||||
${linksHtmlWithClass}
|
||||
</div>
|
||||
</nav>`,
|
||||
};
|
||||
};
|
||||
204
craft/src/components/basic/SearchBar.tsx
Normal file
204
craft/src/components/basic/SearchBar.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
showButton?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
role="search"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '560px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
<i
|
||||
className="fa fa-search"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '14px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: '#9ca3af',
|
||||
fontSize: '14px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px 12px 40px',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: showButton ? '8px 0 0 8px' : '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showButton && (
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: '0 8px 8px 0',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-search" style={{ fontSize: '13px' }} />
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SearchBarSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SearchBarProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Placeholder */}
|
||||
<div>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: SearchBarProps) => { p.placeholder = e.target.value; })}
|
||||
placeholder="Search..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show Button */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showButton !== false}
|
||||
onChange={(e) => setProp((p: SearchBarProps) => { p.showButton = e.target.checked; })}
|
||||
/>
|
||||
Show Button
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Button Text */}
|
||||
{props.showButton !== false && (
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: SearchBarProps) => { p.buttonText = e.target.value; })}
|
||||
placeholder="Search"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SearchBar.craft = {
|
||||
displayName: 'Search Bar',
|
||||
props: {
|
||||
placeholder: 'Search...',
|
||||
buttonText: 'Search',
|
||||
showButton: true,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SearchBarSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const formStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '560px',
|
||||
...style,
|
||||
});
|
||||
|
||||
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
|
||||
|
||||
const btnHtml = showButton
|
||||
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px"></i>${esc(buttonText)}</button>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<div style="position:relative;flex:1">
|
||||
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none"></i>
|
||||
<input type="search" placeholder="${esc(placeholder)}" style="${inputStyleStr}" />
|
||||
</div>
|
||||
${btnHtml}
|
||||
</form>`,
|
||||
};
|
||||
};
|
||||
444
craft/src/components/basic/SocialLinks.tsx
Normal file
444
craft/src/components/basic/SocialLinks.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface SocialLinksProps {
|
||||
links?: SocialLink[];
|
||||
iconSize?: string;
|
||||
iconColor?: string;
|
||||
iconBgColor?: string;
|
||||
iconShape?: 'none' | 'circle' | 'square' | 'rounded';
|
||||
gap?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
facebook: 'fa-facebook',
|
||||
twitter: 'fa-twitter',
|
||||
instagram: 'fa-instagram',
|
||||
linkedin: 'fa-linkedin',
|
||||
youtube: 'fa-youtube',
|
||||
github: 'fa-github',
|
||||
tiktok: 'fa-music',
|
||||
pinterest: 'fa-pinterest',
|
||||
snapchat: 'fa-snapchat',
|
||||
whatsapp: 'fa-whatsapp',
|
||||
};
|
||||
|
||||
const platformLabels: Record<string, string> = {
|
||||
facebook: 'Facebook',
|
||||
twitter: 'Twitter / X',
|
||||
instagram: 'Instagram',
|
||||
linkedin: 'LinkedIn',
|
||||
youtube: 'YouTube',
|
||||
github: 'GitHub',
|
||||
tiktok: 'TikTok',
|
||||
pinterest: 'Pinterest',
|
||||
snapchat: 'Snapchat',
|
||||
whatsapp: 'WhatsApp',
|
||||
};
|
||||
|
||||
const allPlatforms = Object.keys(platformIcons);
|
||||
|
||||
const defaultLinks: SocialLink[] = [
|
||||
{ platform: 'facebook', url: '#' },
|
||||
{ platform: 'twitter', url: '#' },
|
||||
{ platform: 'instagram', url: '#' },
|
||||
{ platform: 'linkedin', url: '#' },
|
||||
];
|
||||
|
||||
const getShapeStyle = (shape: string, size: string): CSSProperties => {
|
||||
if (shape === 'none') return {};
|
||||
const numSize = parseInt(size) || 24;
|
||||
const boxSize = `${numSize + 16}px`;
|
||||
const base: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: boxSize,
|
||||
height: boxSize,
|
||||
};
|
||||
if (shape === 'circle') return { ...base, borderRadius: '50%' };
|
||||
if (shape === 'square') return { ...base, borderRadius: '0' };
|
||||
if (shape === 'rounded') return { ...base, borderRadius: '6px' };
|
||||
return base;
|
||||
};
|
||||
|
||||
const alignMap: Record<string, string> = {
|
||||
left: 'flex-start',
|
||||
center: 'center',
|
||||
right: 'flex-end',
|
||||
};
|
||||
|
||||
export const SocialLinks: UserComponent<SocialLinksProps> = ({
|
||||
links = defaultLinks,
|
||||
iconSize = '20px',
|
||||
iconColor = '#ffffff',
|
||||
iconBgColor = '#374151',
|
||||
iconShape = 'circle',
|
||||
gap = '10px',
|
||||
alignment = 'center',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
justifyContent: alignMap[alignment] || 'center',
|
||||
alignItems: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => {
|
||||
const iconClass = platformIcons[link.platform] || 'fa-link';
|
||||
const shapeStyle = getShapeStyle(iconShape, iconSize);
|
||||
const hasBg = iconShape !== 'none';
|
||||
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={platformLabels[link.platform] || link.platform}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: iconColor,
|
||||
backgroundColor: hasBg ? iconBgColor : 'transparent',
|
||||
transition: 'opacity 0.2s',
|
||||
...shapeStyle,
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${iconClass}`} style={{ fontSize: iconSize }} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SocialLinksSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SocialLinksProps,
|
||||
}));
|
||||
|
||||
const links = props.links || defaultLinks;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateLink = (index: number, field: keyof SocialLink, value: string) => {
|
||||
setProp((p: SocialLinksProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = (platform: string) => {
|
||||
setProp((p: SocialLinksProps) => {
|
||||
p.links = [...(p.links || defaultLinks), { platform, url: '#' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
setProp((p: SocialLinksProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated.splice(index, 1);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const usedPlatforms = new Set(links.map((l) => l.platform));
|
||||
const availablePlatforms = allPlatforms.filter((p) => !usedPlatforms.has(p));
|
||||
|
||||
const sizePresets = ['14px', '18px', '20px', '24px', '28px', '32px'];
|
||||
const gapPresets = ['4px', '8px', '10px', '14px', '20px'];
|
||||
const iconColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#a1a1aa'];
|
||||
const bgColorPresets = ['#374151', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#0ea5e9', 'transparent'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Links Editor */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Social Links</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{links.map((link, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 14, width: 20, textAlign: 'center', flex: 'none' }}>
|
||||
<i className={`fa ${platformIcons[link.platform] || 'fa-link'}`} style={{ color: '#a1a1aa' }} />
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1 }}>
|
||||
{platformLabels[link.platform] || link.platform}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={link.url}
|
||||
onChange={(e) => updateLink(i, 'url', e.target.value)}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{availablePlatforms.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addLink(e.target.value);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
defaultValue=""
|
||||
style={{ ...inputStyle, width: '100%', padding: '6px', cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">+ Add Platform...</option>
|
||||
{availablePlatforms.map((p) => (
|
||||
<option key={p} value={p}>{platformLabels[p]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.alignment = a; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.alignment === a ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Shape */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Shape</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{(['none', 'circle', 'square', 'rounded'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconShape = s; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.iconShape === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Size */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconSize = s; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.iconSize === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{iconColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.iconColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Background Color */}
|
||||
{props.iconShape !== 'none' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconBgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4,
|
||||
border: c === 'transparent' ? '2px dashed #3f3f46' : '1px solid #3f3f46',
|
||||
backgroundColor: c === 'transparent' ? undefined : c,
|
||||
cursor: 'pointer',
|
||||
outline: props.iconBgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gapPresets.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.gap = g; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SocialLinks.craft = {
|
||||
displayName: 'Social Links',
|
||||
props: {
|
||||
links: defaultLinks,
|
||||
iconSize: '20px',
|
||||
iconColor: '#ffffff',
|
||||
iconBgColor: '#374151',
|
||||
iconShape: 'circle',
|
||||
gap: '10px',
|
||||
alignment: 'center',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SocialLinksSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
|
||||
const links = props.links || defaultLinks;
|
||||
const iconSize = props.iconSize || '20px';
|
||||
const iconColor = props.iconColor || '#ffffff';
|
||||
const iconBgColor = props.iconBgColor || '#374151';
|
||||
const iconShape = props.iconShape || 'circle';
|
||||
const gap = props.gap || '10px';
|
||||
const alignment = props.alignment || 'center';
|
||||
const hasBg = iconShape !== 'none';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
justifyContent: alignMap[alignment] || 'center',
|
||||
alignItems: 'center',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
const numSize = parseInt(iconSize) || 20;
|
||||
const boxSize = `${numSize + 16}px`;
|
||||
|
||||
const getShapeStr = (): string => {
|
||||
const parts: string[] = [
|
||||
`display:inline-flex`,
|
||||
`align-items:center`,
|
||||
`justify-content:center`,
|
||||
`width:${boxSize}`,
|
||||
`height:${boxSize}`,
|
||||
];
|
||||
if (iconShape === 'circle') parts.push('border-radius:50%');
|
||||
else if (iconShape === 'square') parts.push('border-radius:0');
|
||||
else if (iconShape === 'rounded') parts.push('border-radius:6px');
|
||||
return parts.join(';');
|
||||
};
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const iconClass = platformIcons[link.platform] || 'fa-link';
|
||||
const title = platformLabels[link.platform] || link.platform;
|
||||
let aStyle = `text-decoration:none;color:${iconColor};background-color:${hasBg ? iconBgColor : 'transparent'}`;
|
||||
if (hasBg) {
|
||||
aStyle += `;${getShapeStr()}`;
|
||||
}
|
||||
return `<a href="${link.url || '#'}" target="_blank" rel="noopener noreferrer" title="${title}" style="${aStyle}"><i class="fa ${iconClass}" style="font-size:${iconSize}"></i></a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
${linksHtml}
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
107
craft/src/components/basic/Spacer.tsx
Normal file
107
craft/src/components/basic/Spacer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface SpacerProps {
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Spacer: UserComponent<SpacerProps> = ({
|
||||
height = '40px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
height,
|
||||
outline: selected ? '2px dashed #3b82f6' : 'none',
|
||||
...style,
|
||||
...(selected && !style.backgroundColor && !style.background
|
||||
? { background: 'rgba(59,130,246,0.05)' }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SpacerSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SpacerProps,
|
||||
}));
|
||||
|
||||
const heightPresets = ['20px', '40px', '60px', '80px', '120px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: SpacerProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Custom Height</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.height || ''}
|
||||
onChange={(e) => setProp((p: SpacerProps) => { p.height = e.target.value; })}
|
||||
placeholder="e.g. 50px, 5rem"
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Spacer.craft = {
|
||||
displayName: 'Spacer',
|
||||
props: {
|
||||
height: '40px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SpacerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Spacer as any).toHtml = (props: SpacerProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
height: props.height || '40px',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<div${styleStr ? ` style="${styleStr}"` : ''}></div>` };
|
||||
};
|
||||
230
craft/src/components/basic/StarRating.tsx
Normal file
230
craft/src/components/basic/StarRating.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface StarRatingProps {
|
||||
rating?: number;
|
||||
maxStars?: number;
|
||||
size?: string;
|
||||
filledColor?: string;
|
||||
emptyColor?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
rating = 4.5,
|
||||
maxStars = 5,
|
||||
size = '24px',
|
||||
filledColor = '#f59e0b',
|
||||
emptyColor = '#d1d5db',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const stars: React.ReactNode[] = [];
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
if (i <= Math.floor(rating)) {
|
||||
// Full star
|
||||
stars.push(
|
||||
<i
|
||||
key={i}
|
||||
className="fa fa-star"
|
||||
style={{ color: filledColor, fontSize: size }}
|
||||
/>
|
||||
);
|
||||
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
||||
// Half star
|
||||
stars.push(
|
||||
<span key={i} style={{ position: 'relative', display: 'inline-block', fontSize: size }}>
|
||||
<i className="fa fa-star" style={{ color: emptyColor }} />
|
||||
<span style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden', width: '50%' }}>
|
||||
<i className="fa fa-star" style={{ color: filledColor }} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
// Empty star
|
||||
stars.push(
|
||||
<i
|
||||
key={i}
|
||||
className="fa fa-star"
|
||||
style={{ color: emptyColor, fontSize: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={(ref: HTMLSpanElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{stars}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const StarRatingSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as StarRatingProps,
|
||||
}));
|
||||
|
||||
const sizePresets = ['16px', '20px', '24px', '32px', '40px'];
|
||||
const filledColorPresets = ['#f59e0b', '#eab308', '#f97316', '#ef4444', '#ec4899', '#3b82f6', '#10b981', '#18181b'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
|
||||
Rating: {props.rating ?? 4.5}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={props.maxStars || 5}
|
||||
step={0.5}
|
||||
value={props.rating ?? 4.5}
|
||||
onChange={(e) => setProp((p: StarRatingProps) => { p.rating = parseFloat(e.target.value); })}
|
||||
style={{ width: '100%', accentColor: '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Max Stars</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[3, 4, 5, 6, 7, 10].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: StarRatingProps) => {
|
||||
p.maxStars = n;
|
||||
if ((p.rating || 0) > n) p.rating = n;
|
||||
})}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.maxStars === n ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Star Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: StarRatingProps) => { p.size = s; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.size === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Filled Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{filledColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: StarRatingProps) => { p.filledColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.filledColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Empty Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.emptyColor || '#d1d5db'}
|
||||
onChange={(e) => setProp((p: StarRatingProps) => { p.emptyColor = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
StarRating.craft = {
|
||||
displayName: 'Star Rating',
|
||||
props: {
|
||||
rating: 4.5,
|
||||
maxStars: 5,
|
||||
size: '24px',
|
||||
filledColor: '#f59e0b',
|
||||
emptyColor: '#d1d5db',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: StarRatingSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
|
||||
const rating = props.rating ?? 4.5;
|
||||
const maxStars = props.maxStars || 5;
|
||||
const size = props.size || '24px';
|
||||
const filledColor = props.filledColor || '#f59e0b';
|
||||
const emptyColor = props.emptyColor || '#d1d5db';
|
||||
const wrapperStyle = cssPropsToString({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
let starsHtml = '';
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
if (i <= Math.floor(rating)) {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}"></i>`;
|
||||
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
||||
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
|
||||
} else {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}"></i>`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
|
||||
};
|
||||
};
|
||||
158
craft/src/components/basic/TextBlock.tsx
Normal file
158
craft/src/components/basic/TextBlock.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { TypographyControl } from '../../ui/TypographyControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
|
||||
interface TextBlockProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
export const TextBlock: UserComponent<TextBlockProps> = ({
|
||||
text = 'Start typing here...',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const elRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const editedTextRef = useRef<string | null>(null);
|
||||
|
||||
const commitText = useCallback(() => {
|
||||
if (elRef.current) {
|
||||
const newText = elRef.current.innerText;
|
||||
editedTextRef.current = newText;
|
||||
setProp((p: TextBlockProps) => { p.text = newText; });
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && editedTextRef.current !== null) {
|
||||
setProp((p: TextBlockProps) => { p.text = editedTextRef.current!; });
|
||||
editedTextRef.current = null;
|
||||
}
|
||||
}, [selected, setProp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected && editedTextRef.current === null) {
|
||||
elRef.current.innerText = text || '';
|
||||
}
|
||||
}, [text, selected]);
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={(ref: HTMLParagraphElement | null) => {
|
||||
elRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
contentEditable={selected}
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleBlur}
|
||||
onInput={() => { if (elRef.current) editedTextRef.current = elRef.current.innerText; }}
|
||||
style={{
|
||||
outline: 'none',
|
||||
cursor: selected ? 'text' : 'pointer',
|
||||
minHeight: '1em',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TextBlockSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TextBlockProps,
|
||||
}));
|
||||
|
||||
return (
|
||||
<SettingsTabs
|
||||
general={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text Content</label>
|
||||
<textarea
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: TextBlockProps) => { p.text = e.target.value; })}
|
||||
rows={4}
|
||||
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
style={
|
||||
<TypographyControl
|
||||
style={props.style || {}}
|
||||
onChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
/>
|
||||
}
|
||||
advanced={
|
||||
<AdvancedTab
|
||||
style={props.style || {}}
|
||||
onStyleChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
cssId={props.cssId || ''}
|
||||
onCssIdChange={(id) => setProp((p: TextBlockProps) => { p.cssId = id; })}
|
||||
cssClass={props.cssClass || ''}
|
||||
onCssClassChange={(cls) => setProp((p: TextBlockProps) => { p.cssClass = cls; })}
|
||||
hideOnDesktop={props.hideOnDesktop}
|
||||
onHideOnDesktopChange={(v) => setProp((p: TextBlockProps) => { p.hideOnDesktop = v; })}
|
||||
hideOnTablet={props.hideOnTablet}
|
||||
onHideOnTabletChange={(v) => setProp((p: TextBlockProps) => { p.hideOnTablet = v; })}
|
||||
hideOnMobile={props.hideOnMobile}
|
||||
onHideOnMobileChange={(v) => setProp((p: TextBlockProps) => { p.hideOnMobile = v; })}
|
||||
animation={props.animation}
|
||||
onAnimationChange={(v) => setProp((p: TextBlockProps) => { p.animation = v; })}
|
||||
animationDelay={props.animationDelay}
|
||||
onAnimationDelayChange={(v) => setProp((p: TextBlockProps) => { p.animationDelay = v; })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
TextBlock.craft = {
|
||||
displayName: 'Text',
|
||||
props: {
|
||||
text: 'Start typing here...',
|
||||
style: {
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
color: '#3f3f46',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TextBlockSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
const escapedText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
|
||||
};
|
||||
423
craft/src/components/forms/ContactForm.tsx
Normal file
423
craft/src/components/forms/ContactForm.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface ContactFormField {
|
||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface ContactFormProps {
|
||||
fields?: ContactFormField[];
|
||||
submitText?: string;
|
||||
submitColor?: string;
|
||||
formAction?: string;
|
||||
successMessage?: string;
|
||||
style?: CSSProperties;
|
||||
labelColor?: string;
|
||||
inputBg?: string;
|
||||
inputBorder?: string;
|
||||
}
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true },
|
||||
{ type: 'email', label: 'Email', name: 'email', placeholder: 'your@email.com', required: true },
|
||||
{ type: 'tel', label: 'Phone', name: 'phone', placeholder: '(555) 123-4567', required: false },
|
||||
{ type: 'textarea', label: 'Message', name: 'message', placeholder: 'How can we help you?', required: true },
|
||||
];
|
||||
|
||||
export const ContactForm: UserComponent<ContactFormProps> = ({
|
||||
fields = defaultFields,
|
||||
submitText = 'Send Message',
|
||||
submitColor = '#3b82f6',
|
||||
formAction = '#',
|
||||
successMessage = 'Thank you! We\'ll get back to you soon.',
|
||||
style = {},
|
||||
labelColor = '#374151',
|
||||
inputBg = '#ffffff',
|
||||
inputBorder = '#d1d5db',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const inputBaseStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: `1px solid ${inputBorder}`,
|
||||
borderRadius: '6px',
|
||||
backgroundColor: inputBg,
|
||||
color: '#1f2937',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
action={formAction}
|
||||
method="POST"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: labelColor }}>
|
||||
{field.label}
|
||||
{field.required && <span style={{ color: '#ef4444', marginLeft: '2px' }}>*</span>}
|
||||
</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
style={{ ...inputBaseStyle, resize: 'vertical' }}
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<select
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
style={{ ...inputBaseStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">{field.placeholder || 'Select...'}</option>
|
||||
{(field.options || []).map((opt, j) => (
|
||||
<option key={j} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={field.type}
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
style={inputBaseStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: submitColor,
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const fieldTypes: ContactFormField['type'][] = ['text', 'email', 'tel', 'textarea', 'select'];
|
||||
|
||||
const ContactFormSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ContactFormProps,
|
||||
}));
|
||||
|
||||
const fields = props.fields || defaultFields;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateField = (index: number, key: keyof ContactFormField, value: any) => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
const updated = [...(p.fields || defaultFields)];
|
||||
updated[index] = { ...updated[index], [key]: value };
|
||||
p.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
p.fields = [...(p.fields || defaultFields), { type: 'text', label: 'New Field', name: 'new_field', placeholder: '', required: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
const updated = [...(p.fields || defaultFields)];
|
||||
updated.splice(index, 1);
|
||||
p.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const submitColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Form Action */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.formAction || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.formAction = e.target.value; })}
|
||||
placeholder="https://... or /api/submit"
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.successMessage || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.successMessage = e.target.value; })}
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.submitText || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.submitText = e.target.value; })}
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{submitColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: ContactFormProps) => { p.submitColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.submitColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.labelColor || '#374151'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.labelColor = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Background */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Background</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.inputBg || '#ffffff'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBg = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Border */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Border Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.inputBorder || '#d1d5db'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBorder = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fields Editor */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Fields</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<select
|
||||
value={field.type}
|
||||
onChange={(e) => updateField(i, 'type', e.target.value)}
|
||||
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{fieldTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(i, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeField(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={field.name}
|
||||
onChange={(e) => updateField(i, 'name', e.target.value)}
|
||||
placeholder="name attr"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder}
|
||||
onChange={(e) => updateField(i, 'placeholder', e.target.value)}
|
||||
placeholder="Placeholder"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => updateField(i, 'required', e.target.checked)}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
{field.type === 'select' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Options (one per line)</label>
|
||||
<textarea
|
||||
value={(field.options || []).join('\n')}
|
||||
onChange={(e) => updateField(i, 'options', e.target.value.split('\n').filter((s: string) => s.trim()))}
|
||||
rows={3}
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addField}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContactForm.craft = {
|
||||
displayName: 'Contact Form',
|
||||
props: {
|
||||
fields: defaultFields,
|
||||
submitText: 'Send Message',
|
||||
submitColor: '#3b82f6',
|
||||
formAction: '#',
|
||||
successMessage: 'Thank you! We\'ll get back to you soon.',
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
labelColor: '#374151',
|
||||
inputBg: '#ffffff',
|
||||
inputBorder: '#d1d5db',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContactFormSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const formStyle = cssPropsToString({
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
...props.style,
|
||||
});
|
||||
const labelColor = props.labelColor || '#374151';
|
||||
const inputBg = props.inputBg || '#ffffff';
|
||||
const inputBorder = props.inputBorder || '#d1d5db';
|
||||
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
|
||||
|
||||
const fieldsHtml = (props.fields || defaultFields).map((field) => {
|
||||
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
|
||||
const labelHtml = `<label style="font-size:14px;font-weight:500;color:${labelColor}">${esc(field.label)}${reqStar}</label>`;
|
||||
const reqAttr = field.required ? ' required' : '';
|
||||
let inputHtml = '';
|
||||
if (field.type === 'textarea') {
|
||||
inputHtml = `<textarea name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
|
||||
} else if (field.type === 'select') {
|
||||
const opts = (field.options || []).map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
|
||||
inputHtml = `<select name="${esc(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${esc(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||
} else {
|
||||
inputHtml = `<input type="${field.type}" name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
|
||||
}
|
||||
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
padding: '12px 32px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: props.submitColor || '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${fieldsHtml}
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
|
||||
</form>`,
|
||||
};
|
||||
};
|
||||
179
craft/src/components/forms/FormButton.tsx
Normal file
179
craft/src/components/forms/FormButton.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FormButtonProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const FormButton: UserComponent<FormButtonProps> = ({
|
||||
text = 'Submit',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(ref: HTMLButtonElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
type="submit"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: selected ? '2px' : '0',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FormButtonSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FormButtonProps,
|
||||
}));
|
||||
|
||||
const colorPresets = [
|
||||
{ bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
|
||||
{ bg: '#10b981', color: '#ffffff', label: 'Green' },
|
||||
{ bg: '#ef4444', color: '#ffffff', label: 'Red' },
|
||||
{ bg: '#f59e0b', color: '#18181b', label: 'Amber' },
|
||||
{ bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
|
||||
{ bg: '#18181b', color: '#ffffff', label: 'Dark' },
|
||||
];
|
||||
|
||||
const radiusPresets = ['0px', '4px', '6px', '8px', '9999px'];
|
||||
const widthPresets = ['auto', '100%'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: FormButtonProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setProp((p: FormButtonProps) => {
|
||||
p.style = { ...p.style, backgroundColor: preset.bg, color: preset.color };
|
||||
})}
|
||||
title={preset.label}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: preset.bg, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, borderRadius: r }; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{widthPresets.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, width: w }; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.width === w ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{w === 'auto' ? 'Auto' : 'Full Width'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormButton.craft = {
|
||||
displayName: 'Submit Button',
|
||||
props: {
|
||||
text: 'Submit',
|
||||
style: {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FormButtonSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FormButton as any).toHtml = (props: FormButtonProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '12px 32px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = (props.text || 'Submit').replace(/</g, '<').replace(/>/g, '>');
|
||||
return {
|
||||
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
|
||||
};
|
||||
};
|
||||
140
craft/src/components/forms/FormContainer.tsx
Normal file
140
craft/src/components/forms/FormContainer.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FormContainerProps {
|
||||
action?: string;
|
||||
method?: 'GET' | 'POST';
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
action = '#',
|
||||
method = 'POST',
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
action={action}
|
||||
method={method}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '24px',
|
||||
minHeight: '80px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Element
|
||||
id="form-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
|
||||
tag="div"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FormContainerSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FormContainerProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.action || ''}
|
||||
onChange={(e) => setProp((p: FormContainerProps) => { p.action = e.target.value; })}
|
||||
placeholder="https://... or /api/submit"
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Method</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['GET', 'POST'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setProp((p: FormContainerProps) => { p.method = m; })}
|
||||
style={{
|
||||
padding: '4px 12px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.method === m ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FormContainerProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormContainer.craft = {
|
||||
displayName: 'Form',
|
||||
props: {
|
||||
action: '#',
|
||||
method: 'POST',
|
||||
style: {
|
||||
padding: '24px',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e4e4e7',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FormContainerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '24px',
|
||||
...props.style,
|
||||
});
|
||||
return {
|
||||
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
|
||||
};
|
||||
};
|
||||
185
craft/src/components/forms/InputField.tsx
Normal file
185
craft/src/components/forms/InputField.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface InputFieldProps {
|
||||
label?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const InputField: UserComponent<InputFieldProps> = ({
|
||||
label = 'Label',
|
||||
type = 'text',
|
||||
name = 'field',
|
||||
placeholder = '',
|
||||
required = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
borderRadius: '4px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
|
||||
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #d4d4d8',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const InputFieldSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as InputFieldProps,
|
||||
}));
|
||||
|
||||
const typeOptions: InputFieldProps['type'][] = ['text', 'email', 'password', 'number', 'tel', 'url'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.label || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.label = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{typeOptions.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProp((p: InputFieldProps) => { p.type = t; })}
|
||||
style={{
|
||||
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.type === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.name || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.name = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.placeholder = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.required}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.required = e.target.checked; })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
InputField.craft = {
|
||||
displayName: 'Input',
|
||||
props: {
|
||||
label: 'Your Name',
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
placeholder: 'Enter your name',
|
||||
required: false,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: InputFieldSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<input type="${props.type || 'text'}" name="${esc(props.name || 'field')}" placeholder="${esc(props.placeholder || '')}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
307
craft/src/components/forms/SubscribeForm.tsx
Normal file
307
craft/src/components/forms/SubscribeForm.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface SubscribeFormProps {
|
||||
heading?: string;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
buttonColor?: string;
|
||||
layout?: 'inline' | 'stacked';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
buttonText = 'Subscribe',
|
||||
buttonColor = '#3b82f6',
|
||||
layout = 'inline',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '40px 24px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{heading && (
|
||||
<h3 style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '20px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{heading}
|
||||
</h3>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: isInline ? 'row' : 'column',
|
||||
gap: isInline ? '0' : '12px',
|
||||
maxWidth: isInline ? '480px' : '360px',
|
||||
margin: '0 auto',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: isInline ? '8px 0 0 8px' : '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: buttonColor,
|
||||
border: 'none',
|
||||
borderRadius: isInline ? '0 8px 8px 0' : '8px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SubscribeFormSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SubscribeFormProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const buttonColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a', '#eff6ff', '#f0fdf4', '#fef3c7'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.heading = e.target.value; })}
|
||||
placeholder="Subscribe to our newsletter"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Placeholder */}
|
||||
<div>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.placeholder = e.target.value; })}
|
||||
placeholder="Enter your email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Button Text */}
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.buttonText = e.target.value; })}
|
||||
placeholder="Subscribe"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Layout</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'inline'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.layout || 'inline') === 'inline' ? '#3b82f6' : '#27272a',
|
||||
color: (props.layout || 'inline') === 'inline' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'stacked'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.layout === 'stacked' ? '#3b82f6' : '#27272a',
|
||||
color: props.layout === 'stacked' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Stacked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{buttonColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.buttonColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.buttonColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SubscribeForm.craft = {
|
||||
displayName: 'Subscribe Form',
|
||||
props: {
|
||||
heading: 'Subscribe to our newsletter',
|
||||
placeholder: 'Enter your email',
|
||||
buttonText: 'Subscribe',
|
||||
buttonColor: '#3b82f6',
|
||||
layout: 'inline',
|
||||
style: { backgroundColor: '#f8fafc' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SubscribeFormSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
buttonText = 'Subscribe',
|
||||
buttonColor = '#3b82f6',
|
||||
layout = 'inline',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
padding: '40px 24px',
|
||||
textAlign: 'center',
|
||||
...style,
|
||||
});
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${esc(heading)}</h3>`
|
||||
: '';
|
||||
|
||||
const formStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: isInline ? 'row' : 'column',
|
||||
gap: isInline ? '0' : '12px',
|
||||
maxWidth: isInline ? '480px' : '360px',
|
||||
margin: '0 auto',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
|
||||
const inputStyleStr = `flex:1;padding:12px 16px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${isInline ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
padding: '12px 24px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: buttonColor,
|
||||
border: 'none',
|
||||
borderRadius: isInline ? '0 8px 8px 0' : '8px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<input type="email" name="email" placeholder="${esc(placeholder)}" required style="${inputStyleStr}" />
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(buttonText)}</button>
|
||||
</form>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
187
craft/src/components/forms/TextareaField.tsx
Normal file
187
craft/src/components/forms/TextareaField.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface TextareaFieldProps {
|
||||
label?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
||||
label = 'Message',
|
||||
name = 'message',
|
||||
placeholder = '',
|
||||
rows = 4,
|
||||
required = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
borderRadius: '4px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
|
||||
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
required={required}
|
||||
readOnly
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #d4d4d8',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TextareaFieldSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TextareaFieldProps,
|
||||
}));
|
||||
|
||||
const rowsPresets = [2, 3, 4, 6, 8];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.label || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.label = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.name || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.name = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.placeholder = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Rows</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{rowsPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: TextareaFieldProps) => { p.rows = r; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.rows === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.required}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.required = e.target.checked; })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
TextareaField.craft = {
|
||||
displayName: 'Textarea',
|
||||
props: {
|
||||
label: 'Message',
|
||||
name: 'message',
|
||||
placeholder: 'Enter your message',
|
||||
rows: 4,
|
||||
required: false,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TextareaFieldSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<textarea name="${esc(props.name || 'message')}" placeholder="${esc(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
206
craft/src/components/layout/BackgroundSection.tsx
Normal file
206
craft/src/components/layout/BackgroundSection.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface BackgroundSectionProps {
|
||||
bgImage?: string;
|
||||
bgColor?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
bgImage = '',
|
||||
bgColor = '#1e293b',
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0.4,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Content */}
|
||||
<Element
|
||||
id="bg-section-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
maxWidth: innerMaxWidth,
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
}}
|
||||
tag="div"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.bgImage || ''}
|
||||
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.bgImage = e.target.value; })}
|
||||
placeholder="https://... or /storage/assets/..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: BackgroundSectionProps) => { p.bgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{overlayPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: BackgroundSectionProps) => { p.overlayColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
|
||||
Overlay Opacity: {Math.round((props.overlayOpacity ?? 0.4) * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round((props.overlayOpacity ?? 0.4) * 100)}
|
||||
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.overlayOpacity = parseInt(e.target.value, 10) / 100; })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.innerMaxWidth = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
BackgroundSection.craft = {
|
||||
displayName: 'Background Section',
|
||||
props: {
|
||||
bgImage: '',
|
||||
bgColor: '#1e293b',
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0.4,
|
||||
innerMaxWidth: '1200px',
|
||||
style: { padding: '0' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: BackgroundSectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
||||
const outerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
backgroundColor: props.bgColor || '#1e293b',
|
||||
backgroundImage: props.bgImage ? `url(${props.bgImage})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const overlayStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
backgroundColor: props.overlayColor || '#000000',
|
||||
opacity: String(props.overlayOpacity ?? 0.4),
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
const innerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
zIndex: '1',
|
||||
maxWidth: props.innerMaxWidth || '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
});
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
};
|
||||
};
|
||||
298
craft/src/components/layout/ColumnLayout.tsx
Normal file
298
craft/src/components/layout/ColumnLayout.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
type SplitOption =
|
||||
| '100'
|
||||
| '50-50' | '30-70' | '70-30' | '40-60' | '60-40'
|
||||
| '33-33-33' | '25-50-25'
|
||||
| '25-25-25-25'
|
||||
| '20-20-20-20-20'
|
||||
| '16-16-16-16-16-16'
|
||||
| 'equal';
|
||||
|
||||
interface ColumnLayoutProps {
|
||||
columns?: number;
|
||||
split?: SplitOption;
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const splitToWidths: Record<string, string[]> = {
|
||||
'100': ['100%'],
|
||||
'50-50': ['50%', '50%'],
|
||||
'30-70': ['30%', '70%'],
|
||||
'70-30': ['70%', '30%'],
|
||||
'40-60': ['40%', '60%'],
|
||||
'60-40': ['60%', '40%'],
|
||||
'33-33-33': ['33.333%', '33.333%', '33.333%'],
|
||||
'25-50-25': ['25%', '50%', '25%'],
|
||||
'25-25-25-25': ['25%', '25%', '25%', '25%'],
|
||||
'20-20-20-20-20': ['20%', '20%', '20%', '20%', '20%'],
|
||||
'16-16-16-16-16-16': ['16.666%', '16.666%', '16.666%', '16.666%', '16.666%', '16.666%'],
|
||||
};
|
||||
|
||||
function getWidths(split: SplitOption, columns: number): string[] {
|
||||
// Check predefined splits first
|
||||
if (split !== 'equal') {
|
||||
const defined = splitToWidths[split];
|
||||
if (defined && defined.length === columns) return defined;
|
||||
}
|
||||
|
||||
// Try parsing custom split string (e.g., "35-65" or "25-50-25")
|
||||
if (split && split !== 'equal' && split.includes('-')) {
|
||||
const parts = split.split('-').map(Number);
|
||||
if (parts.length === columns && parts.every(n => !isNaN(n) && n > 0)) {
|
||||
return parts.map(n => `${n}%`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: equal widths
|
||||
const w = `${(100 / columns).toFixed(3)}%`;
|
||||
return Array.from({ length: columns }, () => w);
|
||||
}
|
||||
|
||||
export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
columns = 2,
|
||||
split = '50-50',
|
||||
gap = '16px',
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
const widths = getWidths(split, columns);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
width: '100%',
|
||||
minHeight: '60px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{widths.map((w, i) => (
|
||||
<Element
|
||||
key={`col-${i}`}
|
||||
id={`col-${i}`}
|
||||
is={Container}
|
||||
canvas
|
||||
custom={{ className: 'craft-column' }}
|
||||
style={{ flex: `0 0 calc(${w} - ${gap})`, minHeight: '60px', padding: '8px' }}
|
||||
tag="div"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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' }}>
|
||||
{/* Preset layouts */}
|
||||
<div>
|
||||
<label style={labelStyle}>Column Layout</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{presetOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => {
|
||||
setProp((p: ColumnLayoutProps) => { p.columns = opt.columns; p.split = opt.split; });
|
||||
setShowCustom(false);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.split === opt.split && props.columns === opt.columns ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom column count (7-10) */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowCustom(!showCustom)}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: showCustom ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{showCustom ? 'Hide Custom' : 'Custom (7-10 columns)'}
|
||||
</button>
|
||||
{showCustom && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label style={labelStyle}>Number of Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={props.columns || 2}
|
||||
onChange={(e) => {
|
||||
const cols = parseInt(e.target.value);
|
||||
setProp((p: ColumnLayoutProps) => { p.columns = cols; p.split = 'equal'; });
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: '#e4e4e7', minWidth: 24, textAlign: 'center' }}>{props.columns || 2}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gap */}
|
||||
<div>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gapPresets.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: ColumnLayoutProps) => { p.gap = g; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Individual Column Widths */}
|
||||
<div>
|
||||
<label style={labelStyle}>Column Widths (%)</label>
|
||||
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 6 }}>
|
||||
Adjust each column's width. Values should roughly total 100%.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{Array.from({ length: props.columns || 2 }).map((_, i) => {
|
||||
const currentWidths = getWidths(props.split || 'equal', props.columns || 2);
|
||||
const currentPct = parseFloat(currentWidths[i]) || (100 / (props.columns || 2));
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#71717a', minWidth: 40 }}>Col {i + 1}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={90}
|
||||
step={5}
|
||||
value={Math.round(currentPct)}
|
||||
onChange={(e) => {
|
||||
const newPct = parseInt(e.target.value);
|
||||
const cols = props.columns || 2;
|
||||
const widths = getWidths(props.split || 'equal', cols).map(w => parseFloat(w));
|
||||
const oldPct = widths[i];
|
||||
const diff = newPct - oldPct;
|
||||
widths[i] = newPct;
|
||||
// Distribute the difference across other columns proportionally
|
||||
const others = widths.filter((_, j) => j !== i);
|
||||
const otherTotal = others.reduce((a, b) => a + b, 0);
|
||||
if (otherTotal > 0) {
|
||||
for (let j = 0; j < widths.length; j++) {
|
||||
if (j !== i) {
|
||||
widths[j] = widths[j] - (diff * (widths[j] / otherTotal));
|
||||
if (widths[j] < 5) widths[j] = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Normalize to 100%
|
||||
const total = widths.reduce((a, b) => a + b, 0);
|
||||
const normalized = widths.map(w => ((w / total) * 100).toFixed(1) + '%');
|
||||
const customSplit = normalized.map(w => parseFloat(w).toFixed(0)).join('-') as SplitOption;
|
||||
setProp((p: ColumnLayoutProps) => { p.split = customSplit; });
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: '#e4e4e7', minWidth: 35, textAlign: 'right' }}>
|
||||
{Math.round(currentPct)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ColumnLayout.craft = {
|
||||
displayName: 'Columns',
|
||||
props: {
|
||||
columns: 2,
|
||||
split: '50-50',
|
||||
gap: '16px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ColumnLayoutSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
||||
const gap = props.gap || '16px';
|
||||
const outerStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
return {
|
||||
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
};
|
||||
};
|
||||
324
craft/src/components/layout/Container.tsx
Normal file
324
craft/src/components/layout/Container.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { BorderControl } from '../../ui/BorderControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
|
||||
interface ContainerProps {
|
||||
style?: CSSProperties;
|
||||
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
|
||||
children?: React.ReactNode;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
fullWidth?: boolean;
|
||||
contentWidth?: 'boxed' | 'full';
|
||||
}
|
||||
|
||||
export const Container: UserComponent<ContainerProps> = ({
|
||||
style = {},
|
||||
tag = 'div',
|
||||
children,
|
||||
fullWidth = false,
|
||||
contentWidth = 'full',
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const outerStyle: CSSProperties = {
|
||||
minHeight: '40px',
|
||||
...style,
|
||||
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
||||
};
|
||||
|
||||
const needsBoxedWrapper = contentWidth === 'boxed';
|
||||
|
||||
const el = React.createElement(
|
||||
tag,
|
||||
{
|
||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||
style: outerStyle,
|
||||
'data-craft-container': 'true',
|
||||
},
|
||||
needsBoxedWrapper
|
||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto' } }, children)
|
||||
: children,
|
||||
);
|
||||
|
||||
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 }}>
|
||||
{/* 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 ---------- */
|
||||
|
||||
Container.craft = {
|
||||
displayName: 'Container',
|
||||
props: {
|
||||
style: { padding: '20px', minHeight: '100px' },
|
||||
tag: 'div',
|
||||
fullWidth: false,
|
||||
contentWidth: 'full',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContainerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
||||
const tag = props.tag || 'div';
|
||||
const outerCss: CSSProperties = { ...props.style };
|
||||
|
||||
if (props.fullWidth) {
|
||||
outerCss.width = '100vw';
|
||||
outerCss.marginLeft = 'calc(-50vw + 50%)';
|
||||
}
|
||||
|
||||
const styleStr = cssPropsToString(outerCss);
|
||||
|
||||
if (props.contentWidth === 'boxed') {
|
||||
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto' });
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
}
|
||||
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
};
|
||||
81
craft/src/components/layout/FooterZone.tsx
Normal file
81
craft/src/components/layout/FooterZone.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FooterZoneProps {
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FooterZone: UserComponent<FooterZoneProps> = ({ style = {}, children }) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
data-zone="footer"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
borderTop: '1px solid rgba(148,163,184,0.15)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Element id="footer-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
|
||||
{children}
|
||||
</Element>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterZoneSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FooterZoneProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
|
||||
<strong>Footer Zone</strong> -- This section appears on all pages.
|
||||
</p>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FooterZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FooterZone.craft = {
|
||||
displayName: 'Footer Zone',
|
||||
props: {
|
||||
style: { backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 20px', textAlign: 'center' as const },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => false,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FooterZoneSettings,
|
||||
},
|
||||
};
|
||||
|
||||
(FooterZone as any).toHtml = (props: FooterZoneProps, childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</footer>` };
|
||||
};
|
||||
81
craft/src/components/layout/HeaderZone.tsx
Normal file
81
craft/src/components/layout/HeaderZone.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface HeaderZoneProps {
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const HeaderZone: UserComponent<HeaderZoneProps> = ({ style = {}, children }) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
data-zone="header"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
borderBottom: '1px solid rgba(148,163,184,0.15)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Element id="header-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
|
||||
{children}
|
||||
</Element>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderZoneSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HeaderZoneProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
|
||||
<strong>Header Zone</strong> -- This section appears on all pages.
|
||||
</p>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: HeaderZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
HeaderZone.craft = {
|
||||
displayName: 'Header Zone',
|
||||
props: {
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => false, // Header stays at the top, can't be moved
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HeaderZoneSettings,
|
||||
},
|
||||
};
|
||||
|
||||
(HeaderZone as any).toHtml = (props: HeaderZoneProps, childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<header${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</header>` };
|
||||
};
|
||||
401
craft/src/components/layout/Section.tsx
Normal file
401
craft/src/components/layout/Section.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { Container } from './Container';
|
||||
|
||||
/* ---------- Shape Divider SVG Paths ---------- */
|
||||
|
||||
type DividerShape = 'none' | 'wave' | 'angle' | 'curve' | 'triangle' | 'zigzag';
|
||||
|
||||
const DIVIDER_PATHS: Record<Exclude<DividerShape, 'none'>, string> = {
|
||||
wave: 'M0,0 C150,120 350,0 600,60 C850,120 1050,0 1200,60 L1200,120 L0,120 Z',
|
||||
angle: 'M0,0 L1200,120 L0,120 Z',
|
||||
curve: 'M0,0 Q600,140 1200,0 L1200,120 L0,120 Z',
|
||||
triangle: 'M0,120 L600,0 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 {
|
||||
style?: CSSProperties;
|
||||
innerMaxWidth?: string;
|
||||
children?: React.ReactNode;
|
||||
topDivider?: DividerShape;
|
||||
topDividerColor?: string;
|
||||
topDividerHeight?: string;
|
||||
bottomDivider?: DividerShape;
|
||||
bottomDividerColor?: string;
|
||||
bottomDividerHeight?: string;
|
||||
}
|
||||
|
||||
/* ---------- Divider renderer ---------- */
|
||||
|
||||
const ShapeDivider: React.FC<{
|
||||
shape: DividerShape;
|
||||
color: string;
|
||||
height: string;
|
||||
position: 'top' | 'bottom';
|
||||
}> = ({ shape, color, height, position }) => {
|
||||
if (!shape || shape === 'none') return null;
|
||||
const path = DIVIDER_PATHS[shape];
|
||||
if (!path) return null;
|
||||
|
||||
const isTop = position === 'top';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
[position]: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: height || '50px',
|
||||
overflow: 'hidden',
|
||||
lineHeight: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fill: color || '#ffffff',
|
||||
transform: isTop ? 'rotate(180deg)' : undefined,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Section: UserComponent<SectionProps> = ({
|
||||
style = {},
|
||||
innerMaxWidth = '1200px',
|
||||
children,
|
||||
topDivider = 'none',
|
||||
topDividerColor = '#ffffff',
|
||||
topDividerHeight = '50px',
|
||||
bottomDivider = 'none',
|
||||
bottomDividerColor = '#ffffff',
|
||||
bottomDividerHeight = '50px',
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const hasTopDivider = topDivider && topDivider !== 'none';
|
||||
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{hasTopDivider && (
|
||||
<ShapeDivider
|
||||
shape={topDivider}
|
||||
color={topDividerColor}
|
||||
height={topDividerHeight}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
<Element
|
||||
id="section-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{ maxWidth: innerMaxWidth, margin: '0 auto', position: 'relative', zIndex: 1 }}
|
||||
tag="div"
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
{hasBottomDivider && (
|
||||
<ShapeDivider
|
||||
shape={bottomDivider}
|
||||
color={bottomDividerColor}
|
||||
height={bottomDividerHeight}
|
||||
position="bottom"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SectionProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Gradient</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. linear-gradient(135deg, #667eea, #764ba2)"
|
||||
value={(props.style?.background as string) || ''}
|
||||
onChange={(e) => setProp((p: SectionProps) => { p.style = { ...p.style, background: e.target.value }; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding (top/bottom)</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{paddingPresets.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProp((pr: SectionProps) => {
|
||||
pr.style = { ...pr.style, paddingTop: p, paddingBottom: p };
|
||||
})}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.paddingTop === p ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp((p: SectionProps) => { p.innerMaxWidth = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider separator */}
|
||||
<div style={{ borderTop: '1px solid #3f3f46', paddingTop: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7', marginBottom: 10 }}>Shape Dividers</div>
|
||||
|
||||
<DividerSettings
|
||||
label="Top Divider"
|
||||
shape={props.topDivider || 'none'}
|
||||
color={props.topDividerColor || '#ffffff'}
|
||||
height={props.topDividerHeight || '50px'}
|
||||
onShapeChange={(s) => setProp((p: SectionProps) => { p.topDivider = s; })}
|
||||
onColorChange={(c) => setProp((p: SectionProps) => { p.topDividerColor = c; })}
|
||||
onHeightChange={(h) => setProp((p: SectionProps) => { p.topDividerHeight = h; })}
|
||||
/>
|
||||
|
||||
<div style={{ height: 10 }} />
|
||||
|
||||
<DividerSettings
|
||||
label="Bottom Divider"
|
||||
shape={props.bottomDivider || 'none'}
|
||||
color={props.bottomDividerColor || '#ffffff'}
|
||||
height={props.bottomDividerHeight || '50px'}
|
||||
onShapeChange={(s) => setProp((p: SectionProps) => { p.bottomDivider = s; })}
|
||||
onColorChange={(c) => setProp((p: SectionProps) => { p.bottomDividerColor = c; })}
|
||||
onHeightChange={(h) => setProp((p: SectionProps) => { p.bottomDividerHeight = h; })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Section.craft = {
|
||||
displayName: 'Section',
|
||||
props: {
|
||||
style: { padding: '40px 0', backgroundColor: '#ffffff' },
|
||||
innerMaxWidth: '1200px',
|
||||
topDivider: 'none',
|
||||
topDividerColor: '#ffffff',
|
||||
topDividerHeight: '50px',
|
||||
bottomDivider: 'none',
|
||||
bottomDividerColor: '#ffffff',
|
||||
bottomDividerHeight: '50px',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
function buildDividerHtml(
|
||||
shape: DividerShape | undefined,
|
||||
color: string | undefined,
|
||||
height: string | undefined,
|
||||
position: 'top' | 'bottom',
|
||||
): string {
|
||||
if (!shape || shape === 'none') return '';
|
||||
const path = DIVIDER_PATHS[shape];
|
||||
if (!path) return '';
|
||||
|
||||
const isTop = position === 'top';
|
||||
const h = height || '50px';
|
||||
const c = color || '#ffffff';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
[position]: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
height: h,
|
||||
overflow: 'hidden',
|
||||
lineHeight: '0',
|
||||
pointerEvents: 'none',
|
||||
} as CSSProperties);
|
||||
|
||||
const svgTransform = isTop ? ' transform:rotate(180deg);' : '';
|
||||
|
||||
return `<div style="${wrapperStyle}"><svg viewBox="0 0 1200 120" preserveAspectRatio="none" style="width:100%;height:100%;fill:${c};display:block;${svgTransform}"><path d="${path}"/></svg></div>`;
|
||||
}
|
||||
|
||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||
|
||||
const outerStyle = cssPropsToString({
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
...props.style,
|
||||
});
|
||||
const innerStyle = cssPropsToString({
|
||||
maxWidth: props.innerMaxWidth || '1200px',
|
||||
margin: '0 auto',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
zIndex: (hasTopDivider || hasBottomDivider) ? 1 : undefined,
|
||||
} as CSSProperties);
|
||||
|
||||
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
||||
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
||||
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
};
|
||||
};
|
||||
480
craft/src/components/media/ImageBlock.tsx
Normal file
480
craft/src/components/media/ImageBlock.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
|
||||
|
||||
interface ImageBlockProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// Helper: upload a file to the WHP API and return the proxy URL
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
||||
src = PLACEHOLDER_SRC,
|
||||
alt = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({ selected: node.events.selected }));
|
||||
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg');
|
||||
|
||||
// Handle drag-and-drop of files directly onto the image
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={(ref: HTMLImageElement | null) => {
|
||||
imgRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
src={src}
|
||||
alt={alt || 'Image'}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
outline: 'none',
|
||||
cursor: selected ? 'move' : 'pointer',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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', '4px', '8px', '16px', '50%'];
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string | undefined) => {
|
||||
setProp((p: ImageBlockProps) => {
|
||||
p.style = { ...p.style, [key]: value };
|
||||
});
|
||||
}, [setProp]);
|
||||
|
||||
const setAlignment = useCallback((align: Alignment) => {
|
||||
setProp((p: ImageBlockProps) => {
|
||||
const s = { ...p.style };
|
||||
if (align === 'center') {
|
||||
s.marginLeft = 'auto';
|
||||
s.marginRight = 'auto';
|
||||
s.display = 'block';
|
||||
} else if (align === 'right') {
|
||||
s.marginLeft = 'auto';
|
||||
s.marginRight = undefined;
|
||||
s.display = 'block';
|
||||
} else {
|
||||
s.marginLeft = undefined;
|
||||
s.marginRight = undefined;
|
||||
s.display = 'block';
|
||||
}
|
||||
p.style = s;
|
||||
});
|
||||
}, [setProp]);
|
||||
|
||||
// Extract friendly filename from URL
|
||||
const getFriendlyName = (src: string) => {
|
||||
const match = src.match(/filename=([^&]+)/);
|
||||
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
|
||||
return src.split('/').pop() || 'image';
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 };
|
||||
const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' };
|
||||
const btnStyle = (active: boolean): CSSProperties => ({
|
||||
flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Image preview */}
|
||||
<div>
|
||||
<label style={labelStyle}>Image Source</label>
|
||||
|
||||
{!isPlaceholder ? (
|
||||
<>
|
||||
{/* Current image thumbnail + filename + remove */}
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
||||
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image">
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(props.src || '')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Drop zone when no image set */
|
||||
<div
|
||||
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleUpload(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||
Drop image here or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons: Upload + Browse */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline asset browser grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map(asset => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => { setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input (collapsed, for advanced users) */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<input type="text"
|
||||
value={isPlaceholder ? '' : (props.src || '')}
|
||||
onChange={(e) => setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })}
|
||||
placeholder="Or paste image URL..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt Text */}
|
||||
<div>
|
||||
<label style={labelStyle}>Alt Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.alt || ''}
|
||||
onChange={(e) => setProp((p: ImageBlockProps) => { p.alt = e.target.value; })}
|
||||
placeholder="Describe the image..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Width */}
|
||||
<div>
|
||||
<label style={labelStyle}>Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={widthParsed.num}
|
||||
disabled={props.style?.width === 'auto'}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, widthUnit);
|
||||
setPropStyle('width', val || 'auto');
|
||||
}}
|
||||
placeholder="auto"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
if (unit === 'auto') {
|
||||
setPropStyle('width', 'auto');
|
||||
} else {
|
||||
setWidthUnit(unit);
|
||||
const num = widthParsed.num || '100';
|
||||
setPropStyle('width', `${num}${unit}`);
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="%">%</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Width */}
|
||||
<div>
|
||||
<label style={labelStyle}>Max Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxWidthParsed.num}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, maxWidthUnit);
|
||||
setPropStyle('maxWidth', val || '100%');
|
||||
}}
|
||||
placeholder="100%"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={maxWidthUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
setMaxWidthUnit(unit);
|
||||
const num = maxWidthParsed.num || '100';
|
||||
setPropStyle('maxWidth', `${num}${unit}`);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="%">%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={heightParsed.num}
|
||||
disabled={props.style?.height === 'auto'}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, heightUnit);
|
||||
setPropStyle('height', val || 'auto');
|
||||
}}
|
||||
placeholder="auto"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
if (unit === 'auto') {
|
||||
setPropStyle('height', 'auto');
|
||||
} else {
|
||||
setHeightUnit(unit);
|
||||
const num = heightParsed.num || '300';
|
||||
setPropStyle('height', `${num}${unit}`);
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Fit (visible when both width and height are explicit values) */}
|
||||
{props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Object Fit</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
|
||||
<button
|
||||
key={fit}
|
||||
onClick={() => setPropStyle('objectFit', fit)}
|
||||
style={btnStyle(props.style?.objectFit === fit)}
|
||||
>
|
||||
{fit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alignment */}
|
||||
<div>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
|
||||
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
|
||||
</button>
|
||||
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
|
||||
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
|
||||
</button>
|
||||
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
|
||||
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border Radius */}
|
||||
<div>
|
||||
<label style={labelStyle}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
|
||||
style={btnStyle(props.style?.borderRadius === r)}
|
||||
>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ImageBlock.craft = {
|
||||
displayName: 'Image',
|
||||
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||
related: { settings: ImageBlockSettings },
|
||||
};
|
||||
|
||||
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
||||
// Skip placeholder/empty images in export
|
||||
const src = props.src || '';
|
||||
if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) {
|
||||
return { html: '' };
|
||||
}
|
||||
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
||||
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '"')}"` : ' alt=""';
|
||||
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` };
|
||||
};
|
||||
173
craft/src/components/media/MapEmbed.tsx
Normal file
173
craft/src/components/media/MapEmbed.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface MapEmbedProps {
|
||||
address?: string;
|
||||
zoom?: number;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
function buildMapUrl(address: string, zoom: number): string {
|
||||
const encoded = encodeURIComponent(address);
|
||||
return `https://maps.google.com/maps?q=${encoded}&z=${zoom}&output=embed`;
|
||||
}
|
||||
|
||||
export const MapEmbed: UserComponent<MapEmbedProps> = ({
|
||||
address = 'New York, NY',
|
||||
zoom = 14,
|
||||
height = '400px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
width: '100%',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={buildMapUrl(address, zoom)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height,
|
||||
border: 'none',
|
||||
borderRadius: (style as any)?.borderRadius || '0px',
|
||||
display: 'block',
|
||||
}}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const MapEmbedSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as MapEmbedProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const heightPresets = ['300px', '400px', '500px', '600px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label style={labelStyle}>Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.address || ''}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.address = e.target.value; })}
|
||||
placeholder="Enter an address or location..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom */}
|
||||
<div>
|
||||
<label style={labelStyle}>Zoom: {props.zoom || 14}</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
value={props.zoom || 14}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.zoom = parseInt(e.target.value, 10); })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: MapEmbedProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={props.height || ''}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.height = e.target.value; })}
|
||||
placeholder="e.g. 400px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
MapEmbed.craft = {
|
||||
displayName: 'Map',
|
||||
props: {
|
||||
address: 'New York, NY',
|
||||
zoom: 14,
|
||||
height: '400px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: MapEmbedSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(MapEmbed as any).toHtml = (props: MapEmbedProps, _childrenHtml: string) => {
|
||||
const {
|
||||
address = 'New York, NY',
|
||||
zoom = 14,
|
||||
height = '400px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
|
||||
const iframeStyle = cssPropsToString({
|
||||
width: '100%',
|
||||
height,
|
||||
border: 'none',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
const src = buildMapUrl(address, zoom);
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${src}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
|
||||
};
|
||||
};
|
||||
794
craft/src/components/media/VideoBlock.tsx
Normal file
794
craft/src/components/media/VideoBlock.tsx
Normal file
@@ -0,0 +1,794 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
type VideoType = 'youtube' | 'vimeo' | 'file' | 'none';
|
||||
|
||||
interface VideoBlockProps {
|
||||
videoUrl?: string;
|
||||
videoType?: VideoType;
|
||||
embedUrl?: string;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
controls?: boolean;
|
||||
isBackground?: boolean;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/* ---------- URL detection ---------- */
|
||||
|
||||
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
|
||||
if (!url) return { type: 'none', embedUrl: '' };
|
||||
|
||||
// YouTube
|
||||
const ytMatch = url.match(
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/
|
||||
);
|
||||
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
|
||||
|
||||
// Vimeo
|
||||
const vmMatch = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` };
|
||||
|
||||
// Direct file
|
||||
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
|
||||
|
||||
// Uploaded asset (proxy URL)
|
||||
if (url.includes('assets-proxy') || url.includes('serve_asset')) return { type: 'file', embedUrl: url };
|
||||
|
||||
return { type: 'none', embedUrl: url };
|
||||
}
|
||||
|
||||
/** Build embed params for YouTube/Vimeo iframes */
|
||||
function buildEmbedParams(
|
||||
baseUrl: string,
|
||||
opts: { autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean }
|
||||
): string {
|
||||
const url = new URL(baseUrl);
|
||||
if (opts.autoplay) url.searchParams.set('autoplay', '1');
|
||||
if (opts.muted) url.searchParams.set('mute', '1');
|
||||
if (opts.loop) url.searchParams.set('loop', '1');
|
||||
if (opts.controls === false) url.searchParams.set('controls', '0');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/* ---------- Upload helper ---------- */
|
||||
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Placeholder ---------- */
|
||||
|
||||
const VIDEO_PLACEHOLDER = (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
background: '#27272a',
|
||||
borderRadius: 8,
|
||||
border: '2px dashed #3f3f46',
|
||||
color: '#71717a',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: 14,
|
||||
textAlign: 'center' as const,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-play-circle" style={{ fontSize: 36, opacity: 0.5 }} />
|
||||
<span>Add a video URL in settings</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ========================================================================
|
||||
Normal (non-background) Video Component
|
||||
======================================================================== */
|
||||
|
||||
export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
videoUrl = '',
|
||||
videoType: _videoTypeProp,
|
||||
embedUrl: _embedUrlProp,
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop = false,
|
||||
controls = true,
|
||||
isBackground = false,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 50,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
} = useNode();
|
||||
|
||||
// Detect type from URL
|
||||
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
|
||||
|
||||
/* ---- Background mode ---- */
|
||||
if (isBackground) {
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => {
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '300px',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Background video layer */}
|
||||
{type === 'file' && embedUrl && (
|
||||
<video
|
||||
src={embedUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
objectFit: 'cover',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(type === 'youtube' || type === 'vimeo') && embedUrl && (
|
||||
<iframe
|
||||
src={buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '177.78vh', // 16:9 ratio overflow
|
||||
height: '100vh',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: 'none',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
allow="autoplay; encrypted-media"
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
{type === 'none' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: '#1e293b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#71717a',
|
||||
fontSize: 14,
|
||||
fontFamily: 'sans-serif',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: (overlayOpacity ?? 50) / 100,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Content drop zone */}
|
||||
<Element
|
||||
id="video-bg-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
maxWidth: innerMaxWidth,
|
||||
margin: '0 auto',
|
||||
padding: '80px 20px',
|
||||
}}
|
||||
tag="div"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Normal mode ---- */
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => {
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{type === 'none' && VIDEO_PLACEHOLDER}
|
||||
|
||||
{(type === 'youtube' || type === 'vimeo') && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'file' && (
|
||||
<video
|
||||
src={embedUrl}
|
||||
autoPlay={autoplay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
controls={controls}
|
||||
playsInline
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ========================================================================
|
||||
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
|
||||
======================================================================== */
|
||||
|
||||
VideoBlock.craft = {
|
||||
displayName: 'Video',
|
||||
props: {
|
||||
videoUrl: '',
|
||||
videoType: 'none',
|
||||
embedUrl: '',
|
||||
autoplay: false,
|
||||
muted: true,
|
||||
loop: false,
|
||||
controls: true,
|
||||
isBackground: false,
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 50,
|
||||
innerMaxWidth: '1200px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: VideoBlockSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ========================================================================
|
||||
HTML Export
|
||||
======================================================================== */
|
||||
|
||||
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
|
||||
const {
|
||||
videoUrl = '',
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop: doLoop = false,
|
||||
controls = true,
|
||||
isBackground = false,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 50,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
|
||||
|
||||
/* ---- Background mode export ---- */
|
||||
if (isBackground) {
|
||||
const outerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '300px',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
});
|
||||
const overlayStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
backgroundColor: overlayColor,
|
||||
opacity: String(overlayOpacity / 100),
|
||||
zIndex: '1',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
const innerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
zIndex: '2',
|
||||
maxWidth: innerMaxWidth,
|
||||
margin: '0 auto',
|
||||
padding: '80px 20px',
|
||||
});
|
||||
|
||||
let videoHtml = '';
|
||||
if (type === 'file' && embedUrl) {
|
||||
const vidStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
objectFit: 'cover',
|
||||
zIndex: '0',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
videoHtml = `<video src="${embedUrl}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
|
||||
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
|
||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
|
||||
const ifrStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '177.78vh',
|
||||
height: '100vh',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: 'none',
|
||||
zIndex: '0',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
videoHtml = `<iframe src="${iframeSrc}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${videoHtml}<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- Normal mode export ---- */
|
||||
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
|
||||
|
||||
if (type === 'none' || !embedUrl) {
|
||||
return { html: '' };
|
||||
}
|
||||
|
||||
if (type === 'youtube' || type === 'vimeo') {
|
||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
|
||||
const containerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: '0',
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
const iframeStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
});
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${iframeSrc}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct file
|
||||
const vidAttrs: string[] = [];
|
||||
if (autoplay) vidAttrs.push('autoplay');
|
||||
if (muted) vidAttrs.push('muted');
|
||||
if (doLoop) vidAttrs.push('loop');
|
||||
if (controls) vidAttrs.push('controls');
|
||||
vidAttrs.push('playsinline');
|
||||
const vidStyle = cssPropsToString({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${embedUrl}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
||||
};
|
||||
};
|
||||
81
craft/src/components/resolver.ts
Normal file
81
craft/src/components/resolver.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Container } from './layout/Container';
|
||||
import { Section } from './layout/Section';
|
||||
import { ColumnLayout } from './layout/ColumnLayout';
|
||||
import { BackgroundSection } from './layout/BackgroundSection';
|
||||
import { Heading } from './basic/Heading';
|
||||
import { TextBlock } from './basic/TextBlock';
|
||||
import { ButtonLink } from './basic/ButtonLink';
|
||||
import { Logo } from './basic/Logo';
|
||||
import { Menu } from './basic/Menu';
|
||||
import { Navbar } from './basic/Navbar';
|
||||
import { Footer } from './basic/Footer';
|
||||
import { Divider } from './basic/Divider';
|
||||
import { Spacer } from './basic/Spacer';
|
||||
import { Icon } from './basic/Icon';
|
||||
import { ImageBlock } from './media/ImageBlock';
|
||||
import { VideoBlock } from './media/VideoBlock';
|
||||
import { MapEmbed } from './media/MapEmbed';
|
||||
import { HeroSimple } from './sections/HeroSimple';
|
||||
import { FeaturesGrid } from './sections/FeaturesGrid';
|
||||
import { CTASection } from './sections/CTASection';
|
||||
import { Countdown } from './sections/Countdown';
|
||||
import { Testimonials } from './sections/Testimonials';
|
||||
import { FormContainer } from './forms/FormContainer';
|
||||
import { InputField } from './forms/InputField';
|
||||
import { TextareaField } from './forms/TextareaField';
|
||||
import { FormButton } from './forms/FormButton';
|
||||
import { ContactForm } from './forms/ContactForm';
|
||||
import { StarRating } from './basic/StarRating';
|
||||
import { SocialLinks } from './basic/SocialLinks';
|
||||
import { CallToAction } from './sections/CallToAction';
|
||||
import { Accordion } from './sections/Accordion';
|
||||
import { Tabs } from './sections/Tabs';
|
||||
import { PricingTable } from './sections/PricingTable';
|
||||
import { Gallery } from './sections/Gallery';
|
||||
import { ContentSlider } from './sections/ContentSlider';
|
||||
import { NumberCounter } from './sections/NumberCounter';
|
||||
import { SubscribeForm } from './forms/SubscribeForm';
|
||||
import { SearchBar } from './basic/SearchBar';
|
||||
import { HtmlBlock } from './basic/HtmlBlock';
|
||||
|
||||
export const componentResolver = {
|
||||
Container,
|
||||
Section,
|
||||
ColumnLayout,
|
||||
BackgroundSection,
|
||||
Heading,
|
||||
TextBlock,
|
||||
ButtonLink,
|
||||
Logo,
|
||||
Menu,
|
||||
Navbar,
|
||||
Footer,
|
||||
Divider,
|
||||
Spacer,
|
||||
Icon,
|
||||
ImageBlock,
|
||||
VideoBlock,
|
||||
MapEmbed,
|
||||
HeroSimple,
|
||||
FeaturesGrid,
|
||||
CTASection,
|
||||
Countdown,
|
||||
Testimonials,
|
||||
FormContainer,
|
||||
InputField,
|
||||
TextareaField,
|
||||
FormButton,
|
||||
ContactForm,
|
||||
StarRating,
|
||||
SocialLinks,
|
||||
CallToAction,
|
||||
Accordion,
|
||||
Tabs,
|
||||
PricingTable,
|
||||
Gallery,
|
||||
ContentSlider,
|
||||
NumberCounter,
|
||||
SubscribeForm,
|
||||
SearchBar,
|
||||
HtmlBlock,
|
||||
};
|
||||
330
craft/src/components/sections/Accordion.tsx
Normal file
330
craft/src/components/sections/Accordion.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface AccordionItem {
|
||||
title: string;
|
||||
content: string;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
items?: AccordionItem[];
|
||||
style?: CSSProperties;
|
||||
headerBg?: string;
|
||||
headerColor?: string;
|
||||
contentBg?: string;
|
||||
borderColor?: string;
|
||||
}
|
||||
|
||||
const defaultItems: AccordionItem[] = [
|
||||
{ title: 'What is this product?', content: 'Our product is a powerful yet easy-to-use tool designed to help you build beautiful websites without writing a single line of code.', isOpen: true },
|
||||
{ title: 'How do I get started?', content: 'Simply sign up for a free account, choose a template, and start customizing. Our drag-and-drop editor makes it easy to create professional pages in minutes.', isOpen: false },
|
||||
{ title: 'Is there a free plan?', content: 'Yes! We offer a generous free tier that includes all core features. Upgrade anytime to unlock advanced capabilities like custom domains and analytics.', isOpen: false },
|
||||
];
|
||||
|
||||
export const Accordion: UserComponent<AccordionProps> = ({
|
||||
items = defaultItems,
|
||||
style = {},
|
||||
headerBg = '#f8fafc',
|
||||
headerColor = '#18181b',
|
||||
contentBg = '#ffffff',
|
||||
borderColor = '#e2e8f0',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [openIndexes, setOpenIndexes] = useState<Set<number>>(() => {
|
||||
const initial = new Set<number>();
|
||||
items.forEach((item, i) => { if (item.isOpen) initial.add(i); });
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggle = (index: number) => {
|
||||
setOpenIndexes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '0px' }}>
|
||||
{items.map((item, i) => {
|
||||
const isOpen = openIndexes.has(i);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderBottom: i === items.length - 1 ? `1px solid ${borderColor}` : 'none',
|
||||
...(i === 0 ? { borderTopLeftRadius: '8px', borderTopRightRadius: '8px' } : {}),
|
||||
...(i === items.length - 1 ? { borderBottomLeftRadius: '8px', borderBottomRightRadius: '8px', borderBottom: `1px solid ${borderColor}` } : {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggle(i)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: headerBg,
|
||||
color: headerColor,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
userSelect: 'none',
|
||||
...(i === 0 ? { borderTopLeftRadius: '7px', borderTopRightRadius: '7px' } : {}),
|
||||
...(i === items.length - 1 && !isOpen ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
|
||||
}}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<span style={{ fontSize: '12px', transition: 'transform 0.2s', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}>▼</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: contentBg,
|
||||
color: '#4b5563',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
borderTop: `1px solid ${borderColor}`,
|
||||
...(i === items.length - 1 ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Header Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorSwatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.headerBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.headerBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Header Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#18181b', '#1f2937', '#374151', '#ffffff', '#e2e8f0', '#3b82f6'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.headerColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.headerColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Content Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.contentBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Border Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#e2e8f0', '#cbd5e1', '#d1d5db', '#3f3f46', '#52525b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.borderColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.borderColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Items</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={item.title} onChange={(e) => updateItem(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={item.content}
|
||||
onChange={(e) => updateItem(i, 'content', e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Accordion.craft = {
|
||||
displayName: 'Accordion',
|
||||
props: {
|
||||
items: defaultItems,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
headerBg: '#f8fafc',
|
||||
headerColor: '#18181b',
|
||||
contentBg: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: AccordionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const headerBg = props.headerBg || '#f8fafc';
|
||||
const headerColor = props.headerColor || '#18181b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
const borderColor = props.borderColor || '#e2e8f0';
|
||||
const items = props.items || defaultItems;
|
||||
|
||||
const panels = items.map((item, i) => {
|
||||
const openAttr = item.isOpen ? ' open' : '';
|
||||
const topRadius = i === 0 ? 'border-top-left-radius:8px;border-top-right-radius:8px;' : '';
|
||||
const bottomRadius = i === items.length - 1 ? 'border-bottom-left-radius:8px;border-bottom-right-radius:8px;' : '';
|
||||
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
|
||||
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}">
|
||||
<summary style="padding:16px 20px;background-color:${headerBg};color:${headerColor};cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
|
||||
${esc(item.title)}
|
||||
</summary>
|
||||
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
|
||||
${esc(item.content)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
|
||||
${panels}
|
||||
</div>
|
||||
<style>details summary::-webkit-details-marker{display:none}details summary::marker{display:none}</style>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
192
craft/src/components/sections/CTASection.tsx
Normal file
192
craft/src/components/sections/CTASection.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface CTASectionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
gradient?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
|
||||
export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
heading = 'Ready to Get Started?',
|
||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText = 'Start Free Trial',
|
||||
buttonHref = '#',
|
||||
gradient = defaultGradient,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
background: gradient,
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
|
||||
{description}
|
||||
</p>
|
||||
<a
|
||||
href={buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#18181b',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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%)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonText = 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 }}>Button URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonHref || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonHref = 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 }}>Gradient</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gradientPresets.map((g) => (
|
||||
<button
|
||||
key={g.label}
|
||||
onClick={() => setProp((p: CTASectionProps) => { p.gradient = g.value; })}
|
||||
title={g.label}
|
||||
style={{
|
||||
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: g.value, cursor: 'pointer',
|
||||
outline: props.gradient === g.value ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
CTASection.craft = {
|
||||
displayName: 'CTA Section',
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText: 'Start Free Trial',
|
||||
buttonHref: '#',
|
||||
gradient: defaultGradient,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CTASectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.gradient || defaultGradient,
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:700px;margin:0 auto">
|
||||
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${esc(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
|
||||
<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText || '')}</a>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
486
craft/src/components/sections/CallToAction.tsx
Normal file
486
craft/src/components/sections/CallToAction.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface CallToActionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
bgType?: 'color' | 'gradient' | 'image';
|
||||
bgValue?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
textColor?: string;
|
||||
buttonColor?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
|
||||
export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
heading = 'Ready to Get Started?',
|
||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText = 'Get Started',
|
||||
buttonHref = '#',
|
||||
secondaryButtonText = '',
|
||||
secondaryButtonHref = '#',
|
||||
bgType = 'gradient',
|
||||
bgValue = defaultGradient,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
textColor = '#ffffff',
|
||||
buttonColor = '#ffffff',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const bgStyle: CSSProperties = {};
|
||||
if (bgType === 'color') {
|
||||
bgStyle.backgroundColor = bgValue;
|
||||
} else if (bgType === 'gradient') {
|
||||
bgStyle.background = bgValue;
|
||||
} else if (bgType === 'image') {
|
||||
bgStyle.backgroundImage = `url(${bgValue})`;
|
||||
bgStyle.backgroundSize = 'cover';
|
||||
bgStyle.backgroundPosition = 'center';
|
||||
}
|
||||
|
||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...bgStyle,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Overlay */}
|
||||
{bgType === 'image' && overlayOpacity > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity / 100,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto', position: 'relative', zIndex: 1 }}>
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: textColor, marginBottom: '12px' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
<p style={{ fontSize: '18px', color: textColor, opacity: 0.85, marginBottom: '28px', lineHeight: '1.6' }}>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<a
|
||||
href={buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: buttonColor,
|
||||
color: buttonTextColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
{secondaryButtonText && (
|
||||
<a
|
||||
href={secondaryButtonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: `2px solid ${textColor}`,
|
||||
}}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<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>
|
||||
|
||||
{/* Primary Button */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonText = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonHref || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonHref = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary Button */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button Text <span style={{ opacity: 0.5 }}>(leave empty to hide)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.secondaryButtonText || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonText = e.target.value; })}
|
||||
placeholder="e.g. Learn More"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.secondaryButtonText && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.secondaryButtonHref || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonHref = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setProp((p: CallToActionProps) => {
|
||||
p.bgType = t;
|
||||
if (t === 'color') p.bgValue = '#2563eb';
|
||||
if (t === 'gradient') p.bgValue = defaultGradient;
|
||||
if (t === 'image') p.bgValue = '';
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.bgType === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background sub-controls */}
|
||||
{props.bgType === 'color' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgValue === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'gradient' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gradientPresets.map((g) => (
|
||||
<button
|
||||
key={g.label}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = g.value; })}
|
||||
title={g.label}
|
||||
style={{
|
||||
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: g.value, cursor: 'pointer',
|
||||
outline: props.bgValue === g.value ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'image' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.bgValue || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.bgValue = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
|
||||
Overlay Opacity: {props.overlayOpacity ?? 0}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={props.overlayOpacity ?? 0}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayOpacity = parseInt(e.target.value); })}
|
||||
style={{ width: '100%', accentColor: '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.overlayColor || '#000000'}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayColor = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{textColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.textColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.textColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{buttonColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.buttonColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.buttonColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
CallToAction.craft = {
|
||||
displayName: 'Call to Action',
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
secondaryButtonText: 'Learn More',
|
||||
secondaryButtonHref: '#',
|
||||
bgType: 'gradient',
|
||||
bgValue: defaultGradient,
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0,
|
||||
textColor: '#ffffff',
|
||||
buttonColor: '#ffffff',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CallToActionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const bgType = props.bgType || 'gradient';
|
||||
const bgValue = props.bgValue || defaultGradient;
|
||||
const textColor = props.textColor || '#ffffff';
|
||||
const buttonColor = props.buttonColor || '#ffffff';
|
||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||
|
||||
const sectionCss: CSSProperties = {
|
||||
position: 'relative',
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
};
|
||||
|
||||
if (bgType === 'color') {
|
||||
sectionCss.backgroundColor = bgValue;
|
||||
} else if (bgType === 'gradient') {
|
||||
sectionCss.background = bgValue;
|
||||
} else if (bgType === 'image') {
|
||||
sectionCss.backgroundImage = `url(${bgValue})`;
|
||||
sectionCss.backgroundSize = 'cover';
|
||||
sectionCss.backgroundPosition = 'center';
|
||||
}
|
||||
|
||||
const sectionStyle = cssPropsToString(sectionCss);
|
||||
|
||||
let overlayHtml = '';
|
||||
if (bgType === 'image' && (props.overlayOpacity || 0) > 0) {
|
||||
const overlayStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
backgroundColor: props.overlayColor || '#000000',
|
||||
opacity: String((props.overlayOpacity || 0) / 100) as any,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
|
||||
}
|
||||
|
||||
let secondaryBtnHtml = '';
|
||||
if (props.secondaryButtonText) {
|
||||
const secStyle = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: `2px solid ${textColor}`,
|
||||
});
|
||||
secondaryBtnHtml = `\n <a href="${props.secondaryButtonHref || '#'}"${secStyle ? ` style="${secStyle}"` : ''}>${esc(props.secondaryButtonText)}</a>`;
|
||||
}
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: buttonColor,
|
||||
color: buttonTextColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
|
||||
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${esc(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
|
||||
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
|
||||
</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
530
craft/src/components/sections/ContentSlider.tsx
Normal file
530
craft/src/components/sections/ContentSlider.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface Slide {
|
||||
type: 'image' | 'content';
|
||||
imageSrc?: string;
|
||||
heading?: string;
|
||||
text?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
interface ContentSliderProps {
|
||||
slides?: Slide[];
|
||||
autoplay?: boolean;
|
||||
interval?: number;
|
||||
showDots?: boolean;
|
||||
showArrows?: boolean;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultSlides: Slide[] = [
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'First Slide',
|
||||
text: 'Welcome to our showcase',
|
||||
buttonText: 'Learn More',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'Second Slide',
|
||||
text: 'Discover something amazing',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'Third Slide',
|
||||
text: 'Build your future today',
|
||||
buttonText: 'Contact Us',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
|
||||
},
|
||||
];
|
||||
|
||||
export const ContentSlider: UserComponent<ContentSliderProps> = ({
|
||||
slides = defaultSlides,
|
||||
autoplay = true,
|
||||
interval = 5000,
|
||||
showDots = true,
|
||||
showArrows = true,
|
||||
height = '400px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const items = slides.length > 0 ? slides : defaultSlides;
|
||||
|
||||
const goTo = useCallback((index: number) => {
|
||||
setActiveIndex(((index % items.length) + items.length) % items.length);
|
||||
}, [items.length]);
|
||||
|
||||
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
|
||||
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoplay && items.length > 1) {
|
||||
timerRef.current = setInterval(goNext, interval);
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}
|
||||
}, [autoplay, interval, goNext, items.length]);
|
||||
|
||||
const arrowStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
color: '#18181b',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
};
|
||||
|
||||
const renderSlide = (slide: Slide, i: number) => {
|
||||
const bg = slide.imageSrc
|
||||
? { backgroundImage: `url(${slide.imageSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? { backgroundImage: slide.bgColor }
|
||||
: { backgroundColor: slide.bgColor || '#3b82f6' };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: i === activeIndex ? 1 : 0,
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...bg,
|
||||
}}
|
||||
>
|
||||
{(slide.heading || slide.text || slide.buttonText) && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', zIndex: 1 }}>
|
||||
{slide.heading && (
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px', fontFamily: 'Inter, sans-serif', textShadow: '0 2px 8px rgba(0,0,0,0.3)' }}>
|
||||
{slide.heading}
|
||||
</h2>
|
||||
)}
|
||||
{slide.text && (
|
||||
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.9)', marginBottom: '20px', fontFamily: 'Inter, sans-serif', textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||
{slide.text}
|
||||
</p>
|
||||
)}
|
||||
{slide.buttonText && (
|
||||
<a
|
||||
href={slide.buttonHref || '#'}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '12px 28px',
|
||||
background: '#ffffff',
|
||||
color: '#18181b',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
{slide.buttonText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{items.map((slide, i) => renderSlide(slide, i))}
|
||||
|
||||
{showArrows && items.length > 1 && (
|
||||
<>
|
||||
<button onClick={goPrev} style={{ ...arrowStyle, left: '16px' }}>
|
||||
<i className="fa fa-chevron-left" />
|
||||
</button>
|
||||
<button onClick={goNext} style={{ ...arrowStyle, right: '16px' }}>
|
||||
<i className="fa fa-chevron-right" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDots && items.length > 1 && (
|
||||
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '8px', zIndex: 2 }}>
|
||||
{items.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === activeIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
|
||||
transition: 'background-color 0.3s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const ContentSliderSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ContentSliderProps,
|
||||
}));
|
||||
|
||||
const items = props.slides || defaultSlides;
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const heightPresets = ['300px', '400px', '500px', '600px', '80vh'];
|
||||
const intervalPresets = [3000, 4000, 5000, 7000, 10000];
|
||||
const bgPresets = [
|
||||
'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
|
||||
'linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%)',
|
||||
'#18181b',
|
||||
'#0f172a',
|
||||
'#1e293b',
|
||||
'#3b82f6',
|
||||
];
|
||||
|
||||
const updateSlide = (index: number, field: keyof Slide, value: string) => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const updated = [...(p.slides || defaultSlides)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.slides = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addSlide = () => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const current = p.slides || defaultSlides;
|
||||
p.slides = [...current, {
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'New Slide',
|
||||
text: 'Add your content here',
|
||||
buttonText: '',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const removeSlide = (index: number) => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const updated = [...(p.slides || defaultSlides)];
|
||||
updated.splice(index, 1);
|
||||
p.slides = updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: ContentSliderProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: props.height === h ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Autoplay */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.autoplay !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.autoplay = e.target.checked; })}
|
||||
/>
|
||||
Autoplay
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Interval */}
|
||||
{props.autoplay !== false && (
|
||||
<div>
|
||||
<label style={labelStyle}>Interval (ms)</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{intervalPresets.map((ms) => (
|
||||
<button
|
||||
key={ms}
|
||||
onClick={() => setProp((p: ContentSliderProps) => { p.interval = ms; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.interval || 5000) === ms ? '#3b82f6' : '#27272a',
|
||||
color: (props.interval || 5000) === ms ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{ms / 1000}s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Arrows */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showArrows !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.showArrows = e.target.checked; })}
|
||||
/>
|
||||
Show Arrows
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Show Dots */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showDots !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.showDots = e.target.checked; })}
|
||||
/>
|
||||
Show Dots
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Slides */}
|
||||
<div>
|
||||
<label style={labelStyle}>Slides</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((slide, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa', flex: 'none', width: 18 }}>{i + 1}.</span>
|
||||
<select
|
||||
value={slide.type}
|
||||
onChange={(e) => updateSlide(i, 'type', e.target.value)}
|
||||
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<option value="image">Image</option>
|
||||
<option value="content">Content</option>
|
||||
</select>
|
||||
<input type="text" value={slide.heading || ''} onChange={(e) => updateSlide(i, 'heading', e.target.value)} placeholder="Heading" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeSlide(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={slide.imageSrc || ''} onChange={(e) => updateSlide(i, 'imageSrc', e.target.value)} placeholder="Image URL (optional)" style={inputStyle} />
|
||||
<input type="text" value={slide.text || ''} onChange={(e) => updateSlide(i, 'text', e.target.value)} placeholder="Text" style={inputStyle} />
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={slide.buttonText || ''} onChange={(e) => updateSlide(i, 'buttonText', e.target.value)} placeholder="Button text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={slide.buttonHref || ''} onChange={(e) => updateSlide(i, 'buttonHref', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Background</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
onClick={() => updateSlide(i, 'bgColor', bg)}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: bg, cursor: 'pointer',
|
||||
outline: slide.bgColor === bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addSlide}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Slide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContentSlider.craft = {
|
||||
displayName: 'Content Slider',
|
||||
props: {
|
||||
slides: defaultSlides,
|
||||
autoplay: true,
|
||||
interval: 5000,
|
||||
showDots: true,
|
||||
showArrows: true,
|
||||
height: '400px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContentSliderSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
slides = defaultSlides,
|
||||
autoplay = true,
|
||||
interval = 5000,
|
||||
showDots = true,
|
||||
showArrows = true,
|
||||
height = '400px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const items = slides.length > 0 ? slides : defaultSlides;
|
||||
const uid = 'cs_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
});
|
||||
|
||||
const slidesHtml = items.map((slide, i) => {
|
||||
const hasBgImage = slide.imageSrc;
|
||||
const bgStyle = hasBgImage
|
||||
? `background-image:url(${esc(slide.imageSrc!)});background-size:cover;background-position:center`
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? `background-image:${slide.bgColor}`
|
||||
: `background-color:${slide.bgColor || '#3b82f6'}`;
|
||||
|
||||
const contentParts: string[] = [];
|
||||
if (slide.heading) {
|
||||
contentParts.push(`<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px;font-family:Inter,sans-serif;text-shadow:0 2px 8px rgba(0,0,0,0.3)">${esc(slide.heading)}</h2>`);
|
||||
}
|
||||
if (slide.text) {
|
||||
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${esc(slide.text)}</p>`);
|
||||
}
|
||||
if (slide.buttonText) {
|
||||
contentParts.push(`<a href="${esc(slide.buttonHref || '#')}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${esc(slide.buttonText)}</a>`);
|
||||
}
|
||||
|
||||
const innerHtml = contentParts.length > 0
|
||||
? `<div style="text-align:center;padding:20px;z-index:1">${contentParts.join('\n ')}</div>`
|
||||
: '';
|
||||
|
||||
return `<div id="${uid}_s${i}" style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:${i === 0 ? 1 : 0};transition:opacity 0.5s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center;${bgStyle}">
|
||||
${innerHtml}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const arrowsHtml = showArrows && items.length > 1
|
||||
? `<button onclick="${uid}_prev()" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left"></i></button>
|
||||
<button onclick="${uid}_next()" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right"></i></button>`
|
||||
: '';
|
||||
|
||||
const dotsHtml = showDots && items.length > 1
|
||||
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
|
||||
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${slidesHtml}
|
||||
${arrowsHtml}
|
||||
${dotsHtml}
|
||||
<script>
|
||||
(function(){
|
||||
var current=0, total=${items.length}, uid="${uid}";
|
||||
function show(idx){
|
||||
document.getElementById(uid+"_s"+current).style.opacity="0";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
||||
current=((idx%total)+total)%total;
|
||||
document.getElementById(uid+"_s"+current).style.opacity="1";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
||||
}
|
||||
window["${uid}_go"]=show;
|
||||
window["${uid}_next"]=function(){show(current+1);};
|
||||
window["${uid}_prev"]=function(){show(current-1);};
|
||||
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
311
craft/src/components/sections/Countdown.tsx
Normal file
311
craft/src/components/sections/Countdown.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface CountdownProps {
|
||||
targetDate?: string;
|
||||
heading?: string;
|
||||
style?: CSSProperties;
|
||||
digitColor?: string;
|
||||
labelColor?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
interface TimeLeft {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
function getDefaultTargetDate(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function calcTimeLeft(target: string): TimeLeft {
|
||||
const diff = new Date(target).getTime() - Date.now();
|
||||
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
return {
|
||||
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
|
||||
minutes: Math.floor((diff / (1000 * 60)) % 60),
|
||||
seconds: Math.floor((diff / 1000) % 60),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TARGET = getDefaultTargetDate();
|
||||
|
||||
export const Countdown: UserComponent<CountdownProps> = ({
|
||||
targetDate = DEFAULT_TARGET,
|
||||
heading = 'Coming Soon',
|
||||
style = {},
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => calcTimeLeft(targetDate));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeft(calcTimeLeft(targetDate));
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(calcTimeLeft(targetDate));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [targetDate]);
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
const boxStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
minWidth: '80px',
|
||||
};
|
||||
|
||||
const digitStyle: CSSProperties = {
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: digitColor,
|
||||
lineHeight: '1',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
};
|
||||
|
||||
const unitLabelStyle: CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: labelColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
};
|
||||
|
||||
const units: Array<{ label: string; value: number }> = [
|
||||
{ label: 'Days', value: timeLeft.days },
|
||||
{ label: 'Hours', value: timeLeft.hours },
|
||||
{ label: 'Minutes', value: timeLeft.minutes },
|
||||
{ label: 'Seconds', value: timeLeft.seconds },
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: bgColor,
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{heading && (
|
||||
<h2 style={{ fontSize: '32px', fontWeight: '700', color: digitColor, marginBottom: '32px', fontFamily: 'Inter, sans-serif' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', flexWrap: 'wrap' }}>
|
||||
{units.map((u) => (
|
||||
<div key={u.label} style={boxStyle}>
|
||||
<span style={digitStyle}>{pad(u.value)}</span>
|
||||
<span style={unitLabelStyle}>{u.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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' }}>
|
||||
{/* Target date */}
|
||||
<div>
|
||||
<label style={labelStyle}>Target Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={props.targetDate || DEFAULT_TARGET}
|
||||
onChange={(e) => setProp((p: CountdownProps) => { p.targetDate = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: CountdownProps) => { p.heading = e.target.value; })}
|
||||
placeholder="Coming Soon"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Digit color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Digit Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.digitColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.digitColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Label Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.labelColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.labelColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.bgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Countdown.craft = {
|
||||
displayName: 'Countdown',
|
||||
props: {
|
||||
targetDate: DEFAULT_TARGET,
|
||||
heading: 'Coming Soon',
|
||||
style: {},
|
||||
digitColor: '#ffffff',
|
||||
labelColor: 'rgba(255,255,255,0.7)',
|
||||
bgColor: '#18181b',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CountdownSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
targetDate = DEFAULT_TARGET,
|
||||
heading = 'Coming Soon',
|
||||
style = {},
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
} = props;
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
});
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
|
||||
: '';
|
||||
|
||||
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
|
||||
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
|
||||
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
|
||||
|
||||
// Generate a unique ID for this countdown instance
|
||||
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
|
||||
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_h" style="${dStyle}">00</span><span style="${lStyle}">Hours</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_m" style="${dStyle}">00</span><span style="${lStyle}">Minutes</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_s" style="${dStyle}">00</span><span style="${lStyle}">Seconds</span></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var target = new Date("${targetDate}").getTime();
|
||||
function pad(n){ return String(n).padStart(2,'0'); }
|
||||
function update(){
|
||||
var diff = target - Date.now();
|
||||
if(diff<=0){ diff=0; }
|
||||
var d = Math.floor(diff/(1000*60*60*24));
|
||||
var h = Math.floor((diff/(1000*60*60))%24);
|
||||
var m = Math.floor((diff/(1000*60))%60);
|
||||
var s = Math.floor((diff/1000)%60);
|
||||
document.getElementById("${uid}_d").textContent = pad(d);
|
||||
document.getElementById("${uid}_h").textContent = pad(h);
|
||||
document.getElementById("${uid}_m").textContent = pad(m);
|
||||
document.getElementById("${uid}_s").textContent = pad(s);
|
||||
}
|
||||
update();
|
||||
setInterval(update,1000);
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
200
craft/src/components/sections/FeaturesGrid.tsx
Normal file
200
craft/src/components/sections/FeaturesGrid.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FeatureItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface FeaturesGridProps {
|
||||
features?: FeatureItem[];
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultFeatures: FeatureItem[] = [
|
||||
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' },
|
||||
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' },
|
||||
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' },
|
||||
];
|
||||
|
||||
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
features = defaultFeatures,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '1100px', margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
|
||||
{(Array.isArray(features) ? features : []).map((feat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '32px 24px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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 addFeature = () => {
|
||||
setProp((p: FeaturesGridProps) => {
|
||||
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
|
||||
});
|
||||
};
|
||||
|
||||
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' }}>
|
||||
<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' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addFeature}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Feature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FeaturesGrid.craft = {
|
||||
displayName: 'Features Grid',
|
||||
props: {
|
||||
features: defaultFeatures,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FeaturesGridSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const cards = (props.features || defaultFeatures).map((feat) => {
|
||||
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
||||
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
|
||||
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
322
craft/src/components/sections/Gallery.tsx
Normal file
322
craft/src/components/sections/Gallery.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface GalleryImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
interface GalleryProps {
|
||||
images?: GalleryImage[];
|
||||
columns?: number;
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
lightbox?: boolean;
|
||||
}
|
||||
|
||||
const placeholderSvg = (index: number) => {
|
||||
const colors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#ec4899'];
|
||||
const color = colors[index % colors.length];
|
||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill="${color}" width="400" height="300" opacity="0.15"/><rect fill="${color}" x="150" y="100" width="100" height="100" rx="12" opacity="0.3"/><text x="200" y="160" text-anchor="middle" font-family="sans-serif" font-size="24" fill="${color}" opacity="0.6">${index + 1}</text></svg>`)}`;
|
||||
};
|
||||
|
||||
const defaultImages: GalleryImage[] = [
|
||||
{ src: placeholderSvg(0), alt: 'Gallery image 1', caption: 'First image' },
|
||||
{ src: placeholderSvg(1), alt: 'Gallery image 2', caption: 'Second image' },
|
||||
{ src: placeholderSvg(2), alt: 'Gallery image 3', caption: 'Third image' },
|
||||
{ src: placeholderSvg(3), alt: 'Gallery image 4', caption: 'Fourth image' },
|
||||
{ src: placeholderSvg(4), alt: 'Gallery image 5', caption: 'Fifth image' },
|
||||
{ src: placeholderSvg(5), alt: 'Gallery image 6', caption: 'Sixth image' },
|
||||
];
|
||||
|
||||
export const Gallery: UserComponent<GalleryProps> = ({
|
||||
images = defaultImages,
|
||||
columns = 3,
|
||||
gap = '16px',
|
||||
style = {},
|
||||
lightbox = false,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: gap,
|
||||
}}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div key={i} style={{ position: 'relative', overflow: 'hidden', borderRadius: '8px' }}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#f1f5f9',
|
||||
}}
|
||||
/>
|
||||
{img.caption && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
padding: '8px 12px',
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||
color: '#ffffff',
|
||||
fontSize: '12px',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{img.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const GallerySettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as GalleryProps,
|
||||
}));
|
||||
|
||||
const images = props.images || defaultImages;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updateImage = (index: number, field: keyof GalleryImage, value: string) => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const updated = [...(p.images || defaultImages)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.images = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const current = p.images || defaultImages;
|
||||
p.images = [...current, { src: placeholderSvg(current.length), alt: `Gallery image ${current.length + 1}`, caption: '' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const updated = [...(p.images || defaultImages)];
|
||||
updated.splice(index, 1);
|
||||
p.images = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const columnOptions = [2, 3, 4, 5, 6];
|
||||
const gapOptions = ['8px', '12px', '16px', '24px', '32px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{columnOptions.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.columns === n ? '#3b82f6' : '#27272a',
|
||||
color: props.columns === n ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{gapOptions.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.gap = g; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||||
color: props.gap === g ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.lightbox || false}
|
||||
onChange={(e) => setProp((p: GalleryProps) => { p.lightbox = e.target.checked; })}
|
||||
/>
|
||||
Lightbox (click to enlarge in exported HTML)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Images</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{images.map((img, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt=""
|
||||
style={{ width: 32, height: 32, objectFit: 'cover', borderRadius: 4, flexShrink: 0, backgroundColor: '#27272a' }}
|
||||
/>
|
||||
<input type="text" value={img.src} onChange={(e) => updateImage(i, 'src', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeImage(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={img.alt} onChange={(e) => updateImage(i, 'alt', e.target.value)} placeholder="Alt text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={img.caption || ''} onChange={(e) => updateImage(i, 'caption', e.target.value)} placeholder="Caption" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addImage}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Gallery.craft = {
|
||||
displayName: 'Gallery',
|
||||
props: {
|
||||
images: defaultImages,
|
||||
columns: 3,
|
||||
gap: '16px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
lightbox: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: GallerySettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const images = props.images || defaultImages;
|
||||
const columns = props.columns || 3;
|
||||
const gap = props.gap || '16px';
|
||||
const lightbox = props.lightbox || false;
|
||||
|
||||
const galleryId = 'gallery_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const items = images.map((img) => {
|
||||
const caption = img.caption
|
||||
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px">${esc(img.caption)}</div>`
|
||||
: '';
|
||||
const clickAttr = lightbox ? ` onclick="${galleryId}_open('${esc(img.src)}')" style="cursor:pointer;position:relative;overflow:hidden;border-radius:8px"` : ' style="position:relative;overflow:hidden;border-radius:8px"';
|
||||
return `<div${clickAttr}>
|
||||
<img src="${esc(img.src)}" alt="${esc(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||
${caption}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
let lightboxHtml = '';
|
||||
if (lightbox) {
|
||||
lightboxHtml = `
|
||||
<div id="${galleryId}_overlay" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
|
||||
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
||||
</div>
|
||||
<script>
|
||||
function ${galleryId}_open(src){var o=document.getElementById('${galleryId}_overlay');document.getElementById('${galleryId}_img').src=src;o.style.display='flex';}
|
||||
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
|
||||
</script>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
|
||||
${items}
|
||||
</div>${lightboxHtml}
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
457
craft/src/components/sections/HeroSimple.tsx
Normal file
457
craft/src/components/sections/HeroSimple.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface HeroProps {
|
||||
heading?: string;
|
||||
subtitle?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
bgType?: 'color' | 'gradient' | 'image' | 'video';
|
||||
bgColor?: string;
|
||||
bgGradientFrom?: string;
|
||||
bgGradientTo?: string;
|
||||
bgGradientAngle?: number;
|
||||
bgImage?: string;
|
||||
bgVideo?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
textColor?: string;
|
||||
buttonBgColor?: string;
|
||||
buttonTextColor?: string;
|
||||
minHeight?: string;
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// Helper: build the background CSS value
|
||||
function buildBackground(props: HeroProps): string {
|
||||
switch (props.bgType) {
|
||||
case 'gradient':
|
||||
return `linear-gradient(${props.bgGradientAngle || 135}deg, ${props.bgGradientFrom || '#667eea'}, ${props.bgGradientTo || '#764ba2'})`;
|
||||
case 'image':
|
||||
return props.bgImage ? `url('${props.bgImage}') center/cover no-repeat` : '#1e293b';
|
||||
case 'color':
|
||||
default:
|
||||
return props.bgColor || '#1e293b';
|
||||
}
|
||||
}
|
||||
|
||||
export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
heading = 'Build Something Amazing',
|
||||
subtitle = 'Create beautiful websites without writing a single line of code.',
|
||||
buttonText = 'Get Started',
|
||||
buttonHref = '#',
|
||||
secondaryButtonText = '',
|
||||
secondaryButtonHref = '#',
|
||||
bgType = 'color',
|
||||
bgColor = '#1e293b',
|
||||
bgGradientFrom = '#667eea',
|
||||
bgGradientTo = '#764ba2',
|
||||
bgGradientAngle = 135,
|
||||
bgImage = '',
|
||||
bgVideo = '',
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
textColor = '#ffffff',
|
||||
buttonBgColor = '#3b82f6',
|
||||
buttonTextColor = '#ffffff',
|
||||
minHeight = '500px',
|
||||
verticalAlign = 'center',
|
||||
textAlign = 'center',
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const bg = buildBackground({
|
||||
bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage,
|
||||
} as HeroProps);
|
||||
|
||||
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
...style,
|
||||
background: bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
|
||||
backgroundSize: bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: bgType === 'image' ? 'center' : undefined,
|
||||
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[verticalAlign] || 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '60px 20px',
|
||||
}}
|
||||
>
|
||||
{/* Video background */}
|
||||
{bgType === 'video' && bgVideo && (
|
||||
<video
|
||||
src={bgVideo}
|
||||
autoPlay muted loop playsInline
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
||||
objectFit: 'cover', zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overlay (renders AFTER video so it sits on top) */}
|
||||
{overlayOpacity > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity / 100,
|
||||
zIndex: 1,
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
textAlign: textAlign as any,
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '48px', fontWeight: '700', color: textColor,
|
||||
marginBottom: '16px', lineHeight: '1.2',
|
||||
}}>
|
||||
{heading}
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '20px', color: textColor,
|
||||
opacity: 0.85, marginBottom: '32px', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-line',
|
||||
}}>
|
||||
{subtitle}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
|
||||
{buttonText && (
|
||||
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{
|
||||
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
|
||||
color: buttonTextColor, textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: '600', fontSize: '16px',
|
||||
}}>
|
||||
{buttonText}
|
||||
</a>
|
||||
)}
|
||||
{secondaryButtonText && (
|
||||
<a href={secondaryButtonHref} onClick={(e) => e.preventDefault()} style={{
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: 'transparent', color: textColor,
|
||||
textDecoration: 'none', borderRadius: '8px', fontWeight: '600',
|
||||
fontSize: '16px', border: `2px solid ${textColor}`,
|
||||
}}>
|
||||
{secondaryButtonText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* 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>
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={props.buttonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Button URL</label>
|
||||
<input type="text" value={props.buttonHref || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Secondary Button Text</label>
|
||||
<input type="text" value={props.secondaryButtonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
|
||||
<button key={t} onClick={() => setProp((p: HeroProps) => { p.bgType = t; })}
|
||||
style={btnStyle(props.bgType === t)}>
|
||||
{t === 'color' ? 'Color' : t === 'gradient' ? 'Gradient' : t === 'image' ? 'Image' : 'Video'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background controls based on type */}
|
||||
{props.bgType === 'color' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgColor || '#1e293b'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgColor || '#1e293b'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'gradient' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>From</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgGradientFrom || '#667eea'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgGradientFrom || '#667eea'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
||||
style={{ ...inputStyle, fontSize: 10 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>To</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgGradientTo || '#764ba2'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgGradientTo || '#764ba2'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
||||
style={{ ...inputStyle, fontSize: 10 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Angle: {props.bgGradientAngle || 135}°</label>
|
||||
<input type="range" min={0} max={360} value={props.bgGradientAngle || 135}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientAngle = parseInt(e.target.value); })}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.bgType === 'image' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Image URL</label>
|
||||
<input type="text" value={props.bgImage || ''} placeholder="https://..."
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgImage = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'video' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Video URL</label>
|
||||
<input type="text" value={props.bgVideo || ''} placeholder="https://...mp4"
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgVideo = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{(props.bgType === 'image' || props.bgType === 'video') && (
|
||||
<div>
|
||||
<label style={labelStyle}>Overlay ({props.overlayOpacity || 0}%)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.overlayColor || '#000000'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.overlayColor = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="range" min={0} max={100} value={props.overlayOpacity || 0}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.overlayOpacity = parseInt(e.target.value); })}
|
||||
style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text & Button Colors */}
|
||||
<div>
|
||||
<label style={labelStyle}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.textColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.textColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Button BG</label>
|
||||
<input type="color" value={props.buttonBgColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.buttonBgColor = e.target.value; })}
|
||||
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="color" value={props.buttonTextColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.buttonTextColor = e.target.value; })}
|
||||
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Min Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
|
||||
<button key={h} onClick={() => setProp((p: HeroProps) => { p.minHeight = h; })}
|
||||
style={btnStyle(props.minHeight === h)}>{h === '100vh' ? 'Full' : h}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Vertical Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['top', 'center', 'bottom'] as const).map((v) => (
|
||||
<button key={v} onClick={() => setProp((p: HeroProps) => { p.verticalAlign = v; })}
|
||||
style={btnStyle(props.verticalAlign === v)}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Text Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp((p: HeroProps) => { p.textAlign = a; })}
|
||||
style={btnStyle(props.textAlign === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
HeroSimple.craft = {
|
||||
displayName: 'Hero',
|
||||
props: {
|
||||
heading: 'Build Something Amazing',
|
||||
subtitle: 'Create beautiful websites without writing a single line of code.',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
secondaryButtonText: '',
|
||||
secondaryButtonHref: '#',
|
||||
bgType: 'color',
|
||||
bgColor: '#1e293b',
|
||||
bgGradientFrom: '#667eea',
|
||||
bgGradientTo: '#764ba2',
|
||||
bgGradientAngle: 135,
|
||||
bgImage: '',
|
||||
bgVideo: '',
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0,
|
||||
textColor: '#ffffff',
|
||||
buttonBgColor: '#3b82f6',
|
||||
buttonTextColor: '#ffffff',
|
||||
minHeight: '500px',
|
||||
verticalAlign: 'center',
|
||||
textAlign: 'center',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HeroSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const bg = buildBackground(props);
|
||||
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
|
||||
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: props.bgType === 'image' ? 'center' : undefined,
|
||||
minHeight: props.minHeight || '500px',
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[props.verticalAlign || 'center'],
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
let overlayHtml = '';
|
||||
if ((props.overlayOpacity || 0) > 0) {
|
||||
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${props.overlayColor || '#000'};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
|
||||
}
|
||||
|
||||
let videoHtml = '';
|
||||
if (props.bgType === 'video' && props.bgVideo) {
|
||||
videoHtml = `<video src="${props.bgVideo}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
|
||||
}
|
||||
|
||||
const textAlign = props.textAlign || 'center';
|
||||
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
||||
|
||||
let buttonsHtml = '';
|
||||
if (props.buttonText) {
|
||||
buttonsHtml += `<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:${props.buttonBgColor || '#3b82f6'};color:${props.buttonTextColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText)}</a>`;
|
||||
}
|
||||
if (props.secondaryButtonText) {
|
||||
buttonsHtml += `<a href="${props.secondaryButtonHref || '#'}" style="display:inline-block;padding:14px 36px;background:transparent;color:${props.textColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${props.textColor || '#fff'}">${esc(props.secondaryButtonText)}</a>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section style="${sectionStyle}">
|
||||
${videoHtml}${overlayHtml}
|
||||
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
|
||||
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${esc(props.heading || '')}</h1>
|
||||
<p style="font-size:20px;color:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${esc(props.subtitle || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
368
craft/src/components/sections/NumberCounter.tsx
Normal file
368
craft/src/components/sections/NumberCounter.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface Counter {
|
||||
number: number;
|
||||
suffix: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NumberCounterProps {
|
||||
counters?: Counter[];
|
||||
columns?: number;
|
||||
numberColor?: string;
|
||||
labelColor?: string;
|
||||
numberSize?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultCounters: Counter[] = [
|
||||
{ number: 150, suffix: '+', label: 'Projects' },
|
||||
{ number: 50, suffix: '+', label: 'Clients' },
|
||||
{ number: 10, suffix: '', label: 'Years' },
|
||||
{ number: 99, suffix: '%', label: 'Satisfaction' },
|
||||
];
|
||||
|
||||
export const NumberCounter: UserComponent<NumberCounterProps> = ({
|
||||
counters = defaultCounters,
|
||||
columns = 4,
|
||||
numberColor = '#3b82f6',
|
||||
labelColor = '#6b7280',
|
||||
numberSize = '48px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const items = counters.length > 0 ? counters : defaultCounters;
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: '32px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{items.map((counter, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: numberSize,
|
||||
fontWeight: '700',
|
||||
color: numberColor,
|
||||
lineHeight: '1.1',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{counter.number}{counter.suffix}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '15px',
|
||||
color: labelColor,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{counter.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const NumberCounterSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as NumberCounterProps,
|
||||
}));
|
||||
|
||||
const items = props.counters || defaultCounters;
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const columnOptions = [2, 3, 4, 5, 6];
|
||||
const sizePresets = ['32px', '40px', '48px', '56px', '64px'];
|
||||
const numberColorPresets = ['#3b82f6', '#10b981', '#8b5cf6', '#ef4444', '#f59e0b', '#18181b', '#ec4899', '#0ea5e9'];
|
||||
const labelColorPresets = ['#6b7280', '#374151', '#9ca3af', '#a1a1aa', '#64748b', '#18181b'];
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
|
||||
const updateCounter = (index: number, field: keyof Counter, value: string | number) => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
const updated = [...(p.counters || defaultCounters)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.counters = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addCounter = () => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
p.counters = [...(p.counters || defaultCounters), { number: 100, suffix: '+', label: 'New Stat' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeCounter = (index: number) => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
const updated = [...(p.counters || defaultCounters)];
|
||||
updated.splice(index, 1);
|
||||
p.counters = updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Columns */}
|
||||
<div>
|
||||
<label style={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{columnOptions.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.columns || 4) === n ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Size */}
|
||||
<div>
|
||||
<label style={labelStyle}>Number Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.numberSize = s; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.numberSize || '48px') === s ? '#3b82f6' : '#27272a',
|
||||
color: (props.numberSize || '48px') === s ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Number Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{numberColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.numberColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.numberColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Label Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{labelColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.labelColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.labelColor || '#6b7280') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Counters */}
|
||||
<div>
|
||||
<label style={labelStyle}>Counters</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((counter, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={counter.number}
|
||||
onChange={(e) => updateCounter(i, 'number', parseInt(e.target.value) || 0)}
|
||||
placeholder="Number"
|
||||
style={{ ...inputStyle, width: 70, flex: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={counter.suffix}
|
||||
onChange={(e) => updateCounter(i, 'suffix', e.target.value)}
|
||||
placeholder="Suffix"
|
||||
style={{ ...inputStyle, width: 40, flex: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={counter.label}
|
||||
onChange={(e) => updateCounter(i, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCounter(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addCounter}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Counter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
NumberCounter.craft = {
|
||||
displayName: 'Number Counter',
|
||||
props: {
|
||||
counters: defaultCounters,
|
||||
columns: 4,
|
||||
numberColor: '#3b82f6',
|
||||
labelColor: '#6b7280',
|
||||
numberSize: '48px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: NumberCounterSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
counters = defaultCounters,
|
||||
columns = 4,
|
||||
numberColor = '#3b82f6',
|
||||
labelColor = '#6b7280',
|
||||
numberSize = '48px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const items = counters.length > 0 ? counters : defaultCounters;
|
||||
const uid = 'nc_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
|
||||
const countersHtml = items.map((counter, i) => {
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<span id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${esc(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${esc(counter.suffix)}</span>
|
||||
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${esc(counter.label)}</span>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
|
||||
${countersHtml}
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var uid="${uid}",started=false;
|
||||
function animate(){
|
||||
if(started)return;started=true;
|
||||
for(var i=0;i<${items.length};i++){
|
||||
(function(el){
|
||||
var target=parseInt(el.getAttribute("data-target")),
|
||||
suffix=el.getAttribute("data-suffix")||"",
|
||||
current=0,
|
||||
step=Math.max(1,Math.floor(target/60)),
|
||||
timer=setInterval(function(){
|
||||
current+=step;
|
||||
if(current>=target){current=target;clearInterval(timer);}
|
||||
el.textContent=current+suffix;
|
||||
},16);
|
||||
})(document.getElementById(uid+"_n"+i));
|
||||
}
|
||||
}
|
||||
if("IntersectionObserver"in window){
|
||||
var obs=new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){if(e.isIntersecting){animate();obs.disconnect();}});
|
||||
},{threshold:0.2});
|
||||
obs.observe(document.getElementById(uid));
|
||||
}else{animate();}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
451
craft/src/components/sections/PricingTable.tsx
Normal file
451
craft/src/components/sections/PricingTable.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
buttonText: string;
|
||||
buttonHref: string;
|
||||
isFeatured: boolean;
|
||||
}
|
||||
|
||||
interface PricingTableProps {
|
||||
plans?: PricingPlan[];
|
||||
style?: CSSProperties;
|
||||
featuredBg?: string;
|
||||
bulletType?: string;
|
||||
}
|
||||
|
||||
const bulletChars: Record<string, string> = {
|
||||
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
|
||||
};
|
||||
|
||||
const defaultPlans: PricingPlan[] = [
|
||||
{
|
||||
name: 'Basic',
|
||||
price: '$9',
|
||||
period: '/month',
|
||||
features: ['1 Website', '10 GB Storage', 'Free SSL Certificate', 'Email Support'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
features: ['10 Websites', '100 GB Storage', 'Free SSL Certificate', 'Priority Support', 'Custom Domain', 'Analytics Dashboard'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '$99',
|
||||
period: '/month',
|
||||
features: ['Unlimited Websites', '1 TB Storage', 'Free SSL Certificate', '24/7 Phone Support', 'Custom Domain', 'Advanced Analytics', 'API Access', 'Team Collaboration'],
|
||||
buttonText: 'Contact Sales',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
plans = defaultPlans,
|
||||
style = {},
|
||||
featuredBg = '#3b82f6',
|
||||
bulletType = 'check',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
gap: '24px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{plans.map((plan, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: '1 1 280px',
|
||||
maxWidth: '360px',
|
||||
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '16px',
|
||||
padding: '40px 32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
transform: plan.isFeatured ? 'scale(1.05)' : 'none',
|
||||
boxShadow: plan.isFeatured ? '0 20px 60px rgba(59,130,246,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{plan.isFeatured && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
backgroundColor: '#facc15',
|
||||
color: '#18181b',
|
||||
padding: '4px 16px',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
marginBottom: '8px',
|
||||
marginTop: plan.isFeatured ? '8px' : '0',
|
||||
}}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<span style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
lineHeight: '1',
|
||||
}}>
|
||||
{plan.price}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
|
||||
}}>
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
<ul style={{
|
||||
listStyle: 'none',
|
||||
padding: '0',
|
||||
margin: '0 0 32px 0',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
|
||||
<li key={fi} style={{
|
||||
fontSize: '14px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={plan.buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
display: 'inline-block',
|
||||
padding: '14px 32px',
|
||||
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
|
||||
color: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{plan.buttonText}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: PricingTableProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Featured Plan Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: PricingTableProps) => { p.featuredBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.featuredBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Plans</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{plans.map((plan, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.name} onChange={(e) => updatePlan(i, 'name', e.target.value)} placeholder="Plan Name" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removePlan(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.price} onChange={(e) => updatePlan(i, 'price', e.target.value)} placeholder="$29" style={{ ...inputStyle, width: '60px', flex: 'none' }} />
|
||||
<input type="text" value={plan.period} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={{ ...inputStyle, width: '70px', flex: 'none' }} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', marginLeft: 'auto' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plan.isFeatured}
|
||||
onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)}
|
||||
/>
|
||||
Featured
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.buttonText} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} placeholder="Button Text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={plan.buttonHref} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} placeholder="URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>Features:</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feat, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', gap: 2 }}>
|
||||
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeFeature(i, fi)}
|
||||
style={{ padding: '1px 4px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addFeature(i)}
|
||||
style={{ marginTop: 2, width: '100%', padding: '3px', fontSize: 10, background: '#27272a', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer' }}
|
||||
>
|
||||
+ Feature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addPlan}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
PricingTable.craft = {
|
||||
displayName: 'Pricing Table',
|
||||
props: {
|
||||
plans: defaultPlans,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
featuredBg: '#3b82f6',
|
||||
bulletType: 'check',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: PricingTableSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const bulletType = props.bulletType || 'check';
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const plans = props.plans || defaultPlans;
|
||||
const featuredBg = props.featuredBg || '#3b82f6';
|
||||
|
||||
const cards = plans.map((plan) => {
|
||||
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
||||
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
|
||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
|
||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
|
||||
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
|
||||
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
|
||||
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
|
||||
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
|
||||
|
||||
const featuresHtml = (Array.isArray(plan.features) ? plan.features : []).map((f) =>
|
||||
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${esc(f)}</li>`
|
||||
).join('\n ');
|
||||
|
||||
const badge = plan.isFeatured
|
||||
? `<div style="position:absolute;top:-12px;background-color:#facc15;color:#18181b;padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
|
||||
: '';
|
||||
|
||||
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
|
||||
${badge}
|
||||
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${esc(plan.name)}</h3>
|
||||
<div style="margin-bottom:24px">
|
||||
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${esc(plan.price)}</span>
|
||||
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span>
|
||||
</div>
|
||||
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
|
||||
${featuresHtml}
|
||||
</ul>
|
||||
<a href="${plan.buttonHref || '#'}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${esc(plan.buttonText)}</a>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
339
craft/src/components/sections/Tabs.tsx
Normal file
339
craft/src/components/sections/Tabs.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs?: TabItem[];
|
||||
style?: CSSProperties;
|
||||
activeTabBg?: string;
|
||||
activeTabColor?: string;
|
||||
inactiveTabBg?: string;
|
||||
inactiveTabColor?: string;
|
||||
contentBg?: string;
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
{ label: 'Overview', content: 'Welcome to our platform. We provide the tools you need to build, launch, and grow your online presence. Our intuitive interface makes it simple to get started in minutes.' },
|
||||
{ label: 'Features', content: 'Drag-and-drop editor, responsive templates, custom domains, analytics dashboard, SEO tools, and integrations with your favorite services. Everything you need in one place.' },
|
||||
{ label: 'Support', content: 'Our dedicated support team is available 24/7 to help you with any questions. Access our knowledge base, community forums, or reach out directly via live chat or email.' },
|
||||
];
|
||||
|
||||
export const Tabs: UserComponent<TabsProps> = ({
|
||||
tabs = defaultTabs,
|
||||
style = {},
|
||||
activeTabBg = '#3b82f6',
|
||||
activeTabColor = '#ffffff',
|
||||
inactiveTabBg = '#f1f5f9',
|
||||
inactiveTabColor = '#64748b',
|
||||
contentBg = '#ffffff',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
{/* Tab buttons */}
|
||||
<div style={{ display: 'flex', gap: '2px', borderBottom: '2px solid #e2e8f0' }}>
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === activeIndex ? activeTabBg : inactiveTabBg,
|
||||
color: i === activeIndex ? activeTabColor : inactiveTabColor,
|
||||
transition: 'background-color 0.2s, color 0.2s',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content panel */}
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
backgroundColor: contentBg,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderTop: 'none',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.7',
|
||||
color: '#4b5563',
|
||||
minHeight: '100px',
|
||||
}}
|
||||
>
|
||||
{tabs[activeIndex]?.content || ''}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Active Tab Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorSwatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.activeTabBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.activeTabBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Active Tab Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#18181b', '#1f2937', '#e2e8f0'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.activeTabColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.activeTabColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Inactive Tab Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#f1f5f9', '#e2e8f0', '#f8fafc', '#ffffff', '#27272a', '#18181b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.inactiveTabBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.inactiveTabBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Inactive Tab Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#64748b', '#94a3b8', '#18181b', '#ffffff'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.inactiveTabColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.inactiveTabColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Content Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.contentBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Tabs</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{tabs.map((tab, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={tab.label} onChange={(e) => updateTab(i, 'label', e.target.value)} placeholder="Label" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeTab(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={tab.content}
|
||||
onChange={(e) => updateTab(i, 'content', e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addTab}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Tabs.craft = {
|
||||
displayName: 'Tabs',
|
||||
props: {
|
||||
tabs: defaultTabs,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
activeTabBg: '#3b82f6',
|
||||
activeTabColor: '#ffffff',
|
||||
inactiveTabBg: '#f1f5f9',
|
||||
inactiveTabColor: '#64748b',
|
||||
contentBg: '#ffffff',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TabsSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const tabs = props.tabs || defaultTabs;
|
||||
const activeTabBg = props.activeTabBg || '#3b82f6';
|
||||
const activeTabColor = props.activeTabColor || '#ffffff';
|
||||
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9';
|
||||
const inactiveTabColor = props.inactiveTabColor || '#64748b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
|
||||
const tabId = 'tabs_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const tabButtons = tabs.map((tab, i) => {
|
||||
const isActive = i === 0;
|
||||
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${esc(tab.label)}</button>`;
|
||||
}).join('\n ');
|
||||
|
||||
const tabPanels = tabs.map((tab, i) => {
|
||||
return `<div id="${tabId}_panel_${i}" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${esc(tab.content)}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const switchScript = `<script>
|
||||
function ${tabId}_switch(idx){
|
||||
var total=${tabs.length};
|
||||
for(var i=0;i<total;i++){
|
||||
document.getElementById('${tabId}_panel_'+i).style.display=i===idx?'':'none';
|
||||
var btn=document.getElementById('${tabId}_btn_'+i);
|
||||
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
||||
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
|
||||
}
|
||||
}
|
||||
</script>`;
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto">
|
||||
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
${tabButtons}
|
||||
</div>
|
||||
${tabPanels}
|
||||
</div>
|
||||
${switchScript}
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
421
craft/src/components/sections/Testimonials.tsx
Normal file
421
craft/src/components/sections/Testimonials.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface Testimonial {
|
||||
quote: string;
|
||||
name: string;
|
||||
title: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
interface TestimonialsProps {
|
||||
testimonials?: Testimonial[];
|
||||
layout?: 'grid' | 'single';
|
||||
columns?: number;
|
||||
style?: CSSProperties;
|
||||
cardBg?: string;
|
||||
starColor?: string;
|
||||
}
|
||||
|
||||
const defaultTestimonials: Testimonial[] = [
|
||||
{ quote: 'This product has completely transformed our workflow. Highly recommended for any team.', name: 'Sarah Johnson', title: 'Marketing Director', rating: 5 },
|
||||
{ quote: 'Outstanding support and an incredibly intuitive interface. We saw results from day one.', name: 'Michael Chen', title: 'CTO, TechStart', rating: 5 },
|
||||
{ quote: 'The best investment we have made this year. Simple, powerful, and reliable.', name: 'Emily Rodriguez', title: 'Founder, DesignLab', rating: 4 },
|
||||
];
|
||||
|
||||
function renderStars(count: number, color: string): React.ReactNode {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '2px', justifyContent: 'center', marginBottom: '12px' }}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<i
|
||||
key={i}
|
||||
className={`fa ${i <= count ? 'fa-star' : 'fa-star-o'}`}
|
||||
style={{ color, fontSize: '14px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function starsHtml(count: number, color: string): string {
|
||||
const stars = [1, 2, 3, 4, 5].map((i) =>
|
||||
`<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px"></i>`
|
||||
).join('');
|
||||
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px">${stars}</div>`;
|
||||
}
|
||||
|
||||
export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
testimonials = defaultTestimonials,
|
||||
layout = 'grid',
|
||||
columns = 3,
|
||||
style = {},
|
||||
cardBg = '#f8fafc',
|
||||
starColor = '#f59e0b',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
backgroundColor: cardBg,
|
||||
borderRadius: '12px',
|
||||
padding: '32px 24px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #e2e8f0',
|
||||
};
|
||||
|
||||
const renderCard = (t: Testimonial, i: number) => (
|
||||
<div key={i} style={cardStyle}>
|
||||
{renderStars(t.rating, starColor)}
|
||||
<p style={{ fontSize: '15px', color: '#374151', lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
|
||||
“{t.quote}”
|
||||
</p>
|
||||
<div style={{ fontWeight: '600', fontSize: '14px', color: '#18181b', fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
|
||||
<div style={{ fontSize: '13px', color: '#64748b', fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
|
||||
{layout === 'grid' ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '24px' }}>
|
||||
{items.map((t, i) => renderCard(t, i))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
|
||||
{renderCard(items[currentIndex] || items[0], currentIndex)}
|
||||
{items.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={() => setCurrentIndex((prev) => (prev - 1 + items.length) % items.length)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
|
||||
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, color: '#374151',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-left" />
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{items.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setCurrentIndex(i)}
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: '50%', cursor: 'pointer',
|
||||
backgroundColor: i === currentIndex ? '#3b82f6' : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentIndex((prev) => (prev + 1) % items.length)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
|
||||
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, color: '#374151',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TestimonialsSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TestimonialsProps,
|
||||
}));
|
||||
|
||||
const items = props.testimonials || defaultTestimonials;
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateTestimonial = (index: number, field: keyof Testimonial, value: string | number) => {
|
||||
setProp((p: TestimonialsProps) => {
|
||||
const updated = [...(p.testimonials || defaultTestimonials)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.testimonials = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addTestimonial = () => {
|
||||
setProp((p: TestimonialsProps) => {
|
||||
p.testimonials = [...(p.testimonials || defaultTestimonials), { quote: 'Great experience!', name: 'New Person', title: 'Role', rating: 5 }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeTestimonial = (index: number) => {
|
||||
setProp((p: TestimonialsProps) => {
|
||||
const updated = [...(p.testimonials || defaultTestimonials)];
|
||||
updated.splice(index, 1);
|
||||
p.testimonials = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
const cardBgPresets = ['#f8fafc', '#ffffff', '#f1f5f9', '#e2e8f0', '#27272a', '#1e293b'];
|
||||
const starColorPresets = ['#f59e0b', '#eab308', '#ef4444', '#3b82f6', '#10b981', '#8b5cf6'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Layout</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'grid'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.layout === 'grid' ? '#3b82f6' : '#27272a',
|
||||
color: props.layout === 'grid' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'single'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.layout === 'single' ? '#3b82f6' : '#27272a',
|
||||
color: props.layout === 'single' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Single
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columns (only for grid) */}
|
||||
{props.layout === 'grid' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.columns === n ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Card Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{cardBgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.cardBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.cardBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Star color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Star Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{starColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.starColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.starColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonials list */}
|
||||
<div>
|
||||
<label style={labelStyle}>Testimonials</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((t, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="text" value={t.name} onChange={(e) => updateTestimonial(i, 'name', e.target.value)} placeholder="Name" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeTestimonial(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={t.title} onChange={(e) => updateTestimonial(i, 'title', e.target.value)} placeholder="Title/Role" style={inputStyle} />
|
||||
<textarea
|
||||
value={t.quote}
|
||||
onChange={(e) => updateTestimonial(i, 'quote', e.target.value)}
|
||||
placeholder="Quote..."
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa' }}>Rating:</span>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<i
|
||||
key={n}
|
||||
className={`fa ${n <= t.rating ? 'fa-star' : 'fa-star-o'}`}
|
||||
onClick={() => updateTestimonial(i, 'rating', n)}
|
||||
style={{ color: '#f59e0b', cursor: 'pointer', fontSize: 14 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addTestimonial}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Testimonial
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Testimonials.craft = {
|
||||
displayName: 'Testimonials',
|
||||
props: {
|
||||
testimonials: defaultTestimonials,
|
||||
layout: 'grid',
|
||||
columns: 3,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
cardBg: '#f8fafc',
|
||||
starColor: '#f59e0b',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TestimonialsSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
testimonials = defaultTestimonials,
|
||||
layout = 'grid',
|
||||
columns = 3,
|
||||
style = {},
|
||||
cardBg = '#f8fafc',
|
||||
starColor = '#f59e0b',
|
||||
} = props;
|
||||
|
||||
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
|
||||
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
|
||||
|
||||
const cards = items.map((t) => {
|
||||
return `<div style="${cardCss}">
|
||||
${starsHtml(t.rating, starColor)}
|
||||
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">“${esc(t.quote)}”</p>
|
||||
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${esc(t.name)}</div>
|
||||
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${esc(t.title)}</div>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
if (layout === 'single') {
|
||||
// For single layout, export as grid with 1 column (simpler static export)
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
86
craft/src/constants/presets.ts
Normal file
86
craft/src/constants/presets.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export const TEXT_COLORS = [
|
||||
{ label: 'Dark', value: '#1f2937' },
|
||||
{ label: 'Medium', value: '#374151' },
|
||||
{ label: 'Light', value: '#6b7280' },
|
||||
{ label: 'White', value: '#ffffff' },
|
||||
{ label: 'Blue', value: '#3b82f6' },
|
||||
{ label: 'Green', value: '#10b981' },
|
||||
{ label: 'Red', value: '#ef4444' },
|
||||
{ label: 'Orange', value: '#f59e0b' },
|
||||
];
|
||||
|
||||
export const BG_COLORS = [
|
||||
{ label: 'White', value: '#ffffff' },
|
||||
{ label: 'Off-white', value: '#f9fafb' },
|
||||
{ label: 'Dark', value: '#1f2937' },
|
||||
{ label: 'Darker', value: '#111827' },
|
||||
{ label: 'Blue', value: '#3b82f6' },
|
||||
{ label: 'Green', value: '#10b981' },
|
||||
{ label: 'Purple', value: '#8b5cf6' },
|
||||
{ label: 'Pink', value: '#ec4899' },
|
||||
];
|
||||
|
||||
export const FONT_FAMILIES = [
|
||||
{ label: 'Inter', value: 'Inter, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ label: 'Open Sans', value: 'Open Sans, 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' },
|
||||
];
|
||||
|
||||
export const TEXT_SIZES = [
|
||||
{ label: 'XS', value: '12px' },
|
||||
{ label: 'S', value: '14px' },
|
||||
{ label: 'M', value: '16px' },
|
||||
{ label: 'L', value: '20px' },
|
||||
{ label: 'XL', value: '24px' },
|
||||
{ label: '2XL', value: '32px' },
|
||||
];
|
||||
|
||||
export const FONT_WEIGHTS = [
|
||||
{ label: 'Light', value: '300' },
|
||||
{ label: 'Normal', value: '400' },
|
||||
{ label: 'Medium', value: '500' },
|
||||
{ label: 'Semi', value: '600' },
|
||||
{ label: 'Bold', value: '700' },
|
||||
];
|
||||
|
||||
export const SPACING_PRESETS = [
|
||||
{ label: 'None', value: '0' },
|
||||
{ label: 'S', value: '8px' },
|
||||
{ label: 'M', value: '16px' },
|
||||
{ label: 'L', value: '24px' },
|
||||
{ label: 'XL', value: '32px' },
|
||||
];
|
||||
|
||||
export const RADIUS_PRESETS = [
|
||||
{ label: 'None', value: '0' },
|
||||
{ label: 'S', value: '4px' },
|
||||
{ label: 'M', value: '8px' },
|
||||
{ label: 'L', value: '16px' },
|
||||
{ label: 'Full', value: '9999px' },
|
||||
];
|
||||
|
||||
export const GRADIENTS = [
|
||||
{ label: 'Purple Dream', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ label: 'Pink Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ label: 'Ocean Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ label: 'Green Teal', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
||||
{ label: 'Warm Sunrise', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
|
||||
{ label: 'Soft Pastel', value: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)' },
|
||||
{ label: 'Peach', value: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' },
|
||||
{ label: 'Warm Sand', value: 'linear-gradient(135deg, #f5af19 0%, #f12711 100%)' },
|
||||
{ label: 'Dark Purple', value: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' },
|
||||
{ label: 'Dark Blue', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' },
|
||||
{ label: 'Dark Gray', value: 'linear-gradient(135deg, #1f2937 0%, #111827 100%)' },
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
export const DEVICE_WIDTHS: Record<string, string> = {
|
||||
desktop: '100%',
|
||||
tablet: '768px',
|
||||
mobile: '375px',
|
||||
};
|
||||
157
craft/src/editor/Canvas.tsx
Normal file
157
craft/src/editor/Canvas.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { Frame, Element } from '@craftjs/core';
|
||||
import { Container } from '../components/layout/Container';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { DeviceMode } from '../types';
|
||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
|
||||
interface CanvasProps {
|
||||
device: DeviceMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the actual header/footer content from the Craft.js state as a
|
||||
* non-interactive preview. This is the user's own authored content.
|
||||
*/
|
||||
const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'footer' }> = ({
|
||||
craftState,
|
||||
zone,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!craftState) return null;
|
||||
try {
|
||||
const result = exportBodyHtml(craftState);
|
||||
return result.html || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [craftState]);
|
||||
|
||||
// Set the rendered HTML into the container via ref (user-authored content)
|
||||
useEffect(() => {
|
||||
if (containerRef.current && renderedHtml) {
|
||||
containerRef.current.textContent = '';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderedHtml; // user's own site content
|
||||
while (wrapper.firstChild) {
|
||||
containerRef.current.appendChild(wrapper.firstChild);
|
||||
}
|
||||
}
|
||||
}, [renderedHtml]);
|
||||
|
||||
if (!renderedHtml) {
|
||||
return (
|
||||
<div
|
||||
data-zone-preview={zone}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 40,
|
||||
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
|
||||
padding: '12px 24px',
|
||||
color: zone === 'header' ? '#9ca3af' : '#64748b',
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
fontStyle: 'italic',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{zone === 'header'
|
||||
? 'Header (empty -- click Edit Header in Pages tab)'
|
||||
: 'Footer (empty -- click Edit Footer in Pages tab)'}
|
||||
<div style={{
|
||||
position: 'absolute', top: 2, right: 6,
|
||||
fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
color: '#f59e0b', background: 'rgba(245,158,11,0.12)', padding: '1px 5px', borderRadius: 3,
|
||||
}}>
|
||||
{zone}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-zone-preview={zone}
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
const width = DEVICE_WIDTHS[device];
|
||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||
|
||||
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
|
||||
|
||||
const frameStyle = isEditingHeader
|
||||
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
|
||||
: isEditingFooter
|
||||
? { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' as const }
|
||||
: { minHeight: '100vh', backgroundColor: '#ffffff' };
|
||||
|
||||
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
|
||||
|
||||
return (
|
||||
<div className="editor-canvas">
|
||||
<div
|
||||
className="canvas-device-frame"
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
transition: 'width 0.3s ease',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{(isEditingHeader || isEditingFooter) && (
|
||||
<div style={{
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
borderBottom: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#f59e0b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
<i className={`fa ${isEditingHeader ? 'fa-window-maximize' : 'fa-window-minimize'}`} />
|
||||
Editing {isEditingHeader ? 'Header' : 'Footer'} -- This content will appear on all pages
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
||||
)}
|
||||
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
canvas
|
||||
tag={frameTag}
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
craft/src/editor/EditorShell.tsx
Normal file
54
craft/src/editor/EditorShell.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||
import { RightPanel } from '../panels/right/RightPanel';
|
||||
import { Canvas } from './Canvas';
|
||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
||||
import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
export const EditorShell: React.FC = () => {
|
||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
||||
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
|
||||
const { query } = useEditor();
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Find the selected node id
|
||||
let nodeId: string | null = null;
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0) {
|
||||
nodeId = selected[0];
|
||||
}
|
||||
} catch {
|
||||
// No selection
|
||||
}
|
||||
showMenu(e.clientX, e.clientY, nodeId);
|
||||
}, [query, showMenu]);
|
||||
|
||||
return (
|
||||
<div className="editor-app">
|
||||
<TopBar device={device} onDeviceChange={setDevice} />
|
||||
<div className="editor-container">
|
||||
<LeftPanel />
|
||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||
<Canvas device={device} />
|
||||
</div>
|
||||
<RightPanel />
|
||||
</div>
|
||||
<ContextMenu
|
||||
visible={menuState.visible}
|
||||
x={menuState.x}
|
||||
y={menuState.y}
|
||||
nodeId={menuState.nodeId}
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
craft/src/hooks/useAssets.ts
Normal file
97
craft/src/hooks/useAssets.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { AssetData } from '../types';
|
||||
|
||||
export function useAssets() {
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
const [assets, setAssets] = useState<AssetData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=list_assets&site_id=${whpConfig.siteId}`,
|
||||
{ headers: { 'X-CSRF-Token': whpConfig.csrfToken } }
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Failed to load assets: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
setAssets(data.assets.map((item: any) => ({
|
||||
name: item.name || '',
|
||||
url: item.url || '',
|
||||
type: item.type || 'file',
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
})));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to load assets';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isWHP, whpConfig]);
|
||||
|
||||
const uploadAsset = useCallback(async (file: File): Promise<string | null> => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=upload_asset&site_id=${whpConfig.siteId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (!data.success) throw new Error(data.error || 'Upload failed');
|
||||
|
||||
const newAsset: AssetData = {
|
||||
name: data.name || file.name,
|
||||
url: data.url || '',
|
||||
type: data.type || file.type || 'file',
|
||||
size: file.size,
|
||||
modified: Date.now(),
|
||||
};
|
||||
setAssets((prev) => [newAsset, ...prev]);
|
||||
return newAsset.url;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Upload failed';
|
||||
setError(msg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isWHP, whpConfig]);
|
||||
|
||||
const deleteAsset = useCallback(async (filename: string) => {
|
||||
if (!isWHP || !whpConfig) return;
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=delete_asset&site_id=${whpConfig.siteId}&filename=${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
|
||||
}
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Delete failed: ${resp.status}`);
|
||||
setAssets((prev) => prev.filter((a) => a.name !== filename));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Delete failed';
|
||||
setError(msg);
|
||||
}
|
||||
}, [isWHP, whpConfig]);
|
||||
|
||||
return { assets, loading, error, loadAssets, uploadAsset, deleteAsset };
|
||||
}
|
||||
27
craft/src/hooks/useContextMenu.ts
Normal file
27
craft/src/hooks/useContextMenu.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menuState, setMenuState] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
nodeId: null,
|
||||
});
|
||||
|
||||
const show = useCallback((x: number, y: number, nodeId: string | null) => {
|
||||
setMenuState({ visible: true, x, y, nodeId });
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
setMenuState((prev) => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
return { menuState, show, hide };
|
||||
}
|
||||
103
craft/src/hooks/useKeyboardShortcuts.ts
Normal file
103
craft/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||
if ((el as HTMLElement).isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const { actions, query } = useEditor((state) => {
|
||||
const selectedIds = state.events.selected;
|
||||
const selectedId = selectedIds ? Array.from(selectedIds)[0] : null;
|
||||
return { selectedId };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip when typing in inputs
|
||||
if (isInputFocused()) return;
|
||||
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Ctrl+Z: Undo
|
||||
if (ctrl && !e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
try {
|
||||
actions.history.undo();
|
||||
} catch (err) {
|
||||
// No more undo steps
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Y or Ctrl+Shift+Z: Redo
|
||||
if ((ctrl && e.key === 'y') || (ctrl && e.shiftKey && e.key === 'z') || (ctrl && e.shiftKey && e.key === 'Z')) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
actions.history.redo();
|
||||
} catch (err) {
|
||||
// No more redo steps
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete/Backspace: delete selected node
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0) {
|
||||
const nodeId = selected[0];
|
||||
if (nodeId !== 'ROOT') {
|
||||
actions.delete(nodeId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+D: duplicate selected
|
||||
if (ctrl && (e.key === 'd' || e.key === 'D')) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0) {
|
||||
const nodeId = selected[0];
|
||||
if (nodeId !== 'ROOT') {
|
||||
const node = query.node(nodeId).get();
|
||||
const parentId = node?.data?.parent;
|
||||
if (parentId) {
|
||||
const tree = query.node(nodeId).toNodeTree();
|
||||
actions.addNodeTree(tree, parentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Duplicate failed:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: deselect all
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
try {
|
||||
actions.clearEvents();
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [actions, query]);
|
||||
}
|
||||
199
craft/src/hooks/useWhpApi.ts
Normal file
199
craft/src/hooks/useWhpApi.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
|
||||
export function useWhpApi() {
|
||||
const { query, actions } = useEditor();
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages();
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
|
||||
// Serialize the current canvas state (whatever page is active)
|
||||
const currentCraftState = query.serialize();
|
||||
|
||||
// Export body HTML for the current page
|
||||
let currentHtml = '';
|
||||
let css = '';
|
||||
try {
|
||||
const result = exportBodyHtml(currentCraftState);
|
||||
currentHtml = result.html;
|
||||
css = result.css;
|
||||
} catch (e) {
|
||||
console.error('HTML export failed, saving state only:', e);
|
||||
}
|
||||
|
||||
// Export header HTML from its craft state
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerPage.craftState) {
|
||||
const hResult = exportBodyHtml(headerPage.craftState);
|
||||
headerHtml = hResult.html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Header HTML export failed:', e);
|
||||
}
|
||||
|
||||
// Export footer HTML from its craft state
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerPage.craftState) {
|
||||
const fResult = exportBodyHtml(footerPage.craftState);
|
||||
footerHtml = fResult.html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Footer HTML export failed:', e);
|
||||
}
|
||||
|
||||
// Build the pages array with HTML for each page
|
||||
// For the active page, use the freshly exported HTML from the canvas;
|
||||
// for others, export from their stored craft state
|
||||
const pagesPayload = pages.map((page) => {
|
||||
const filename = (page.slug === 'index' ? 'index' : page.slug) + '.html';
|
||||
let pageHtml = '';
|
||||
|
||||
if (page.id === activePageId) {
|
||||
// Active page: use the current canvas HTML (already exported above)
|
||||
pageHtml = currentHtml;
|
||||
} else if (page.craftState) {
|
||||
try {
|
||||
const pResult = exportBodyHtml(page.craftState);
|
||||
pageHtml = pResult.html;
|
||||
} catch (e) {
|
||||
console.error(`HTML export failed for page ${page.name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
title: page.name,
|
||||
html: pageHtml,
|
||||
};
|
||||
});
|
||||
|
||||
// Build pages_craft_state array: for each page, store its craft state
|
||||
// For the currently active page, always use the fresh canvas state (currentCraftState)
|
||||
// since page.craftState may be stale (not updated until page switch)
|
||||
const pagesGrapesjs = pages.map((page) => ({
|
||||
id: page.id,
|
||||
name: page.name,
|
||||
slug: page.slug,
|
||||
craftState: page.id === activePageId ? currentCraftState : (page.craftState || null),
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
site_id: whpConfig.siteId,
|
||||
name: whpConfig.siteName,
|
||||
html: currentHtml,
|
||||
css,
|
||||
pages: pagesPayload,
|
||||
header_html: headerHtml,
|
||||
footer_html: footerHtml,
|
||||
craft_state: currentCraftState,
|
||||
header_craft_state: headerPage.craftState || null,
|
||||
footer_craft_state: footerPage.craftState || null,
|
||||
pages_craft_state: pagesGrapesjs,
|
||||
};
|
||||
|
||||
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return resp.json();
|
||||
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]);
|
||||
|
||||
const publish = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
|
||||
// First save to ensure staging is up to date
|
||||
await save();
|
||||
|
||||
// Then publish from staging to live
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ site_id: whpConfig.siteId }),
|
||||
},
|
||||
);
|
||||
return resp.json();
|
||||
}, [isWHP, whpConfig, save]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
|
||||
);
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success && data.project) {
|
||||
const proj = data.project;
|
||||
|
||||
// Restore header craft state
|
||||
if (proj.header_craft_state) {
|
||||
setHeaderCraftState(typeof proj.header_craft_state === 'string'
|
||||
? proj.header_craft_state : JSON.stringify(proj.header_craft_state));
|
||||
}
|
||||
|
||||
// Restore footer craft state
|
||||
if (proj.footer_craft_state) {
|
||||
setFooterCraftState(typeof proj.footer_craft_state === 'string'
|
||||
? proj.footer_craft_state : JSON.stringify(proj.footer_craft_state));
|
||||
}
|
||||
|
||||
// Restore pages and load the first page into the canvas
|
||||
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
|
||||
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null }) => ({
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null,
|
||||
})));
|
||||
|
||||
// Load the first page (home) into the canvas
|
||||
const firstPage = proj.pages_craft_state[0];
|
||||
if (firstPage.craftState) {
|
||||
try {
|
||||
const state = typeof firstPage.craftState === 'string'
|
||||
? firstPage.craftState : JSON.stringify(firstPage.craftState);
|
||||
actions.deserialize(state);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load page state:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]);
|
||||
|
||||
const uploadAsset = useCallback(
|
||||
async (file: File) => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=upload_asset&site_id=${whpConfig.siteId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
return resp.json();
|
||||
},
|
||||
[isWHP, whpConfig],
|
||||
);
|
||||
|
||||
return { save, publish, load, uploadAsset, isWHP };
|
||||
}
|
||||
14
craft/src/main.tsx
Normal file
14
craft/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { WhpConfig } from './types';
|
||||
import './styles/editor.css';
|
||||
|
||||
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
|
||||
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App whpConfig={whpConfig} />
|
||||
</React.StrictMode>
|
||||
);
|
||||
283
craft/src/panels/context-menu/ContextMenu.tsx
Normal file
283
craft/src/panels/context-menu/ContextMenu.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
|
||||
interface ContextMenuProps {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
dividerAfter?: boolean;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
visible,
|
||||
x,
|
||||
y,
|
||||
nodeId,
|
||||
onClose,
|
||||
}) => {
|
||||
const { actions, query } = useEditor();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const clipboardRef = useRef<string | null>(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
const getParentId = useCallback((): string | null => {
|
||||
if (!nodeId) return null;
|
||||
try {
|
||||
const node = query.node(nodeId).get();
|
||||
return node?.data?.parent || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [nodeId, query]);
|
||||
|
||||
const duplicate = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
const tree = query.node(nodeId).toSerializedNode();
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
|
||||
// Get the full subtree
|
||||
const freshTree = query.node(nodeId).toNodeTree();
|
||||
const clonedTree = query.parseSerializedNode(freshTree.nodes[freshTree.rootNodeId].data).toNode();
|
||||
|
||||
actions.addNodeTree(freshTree, parentId);
|
||||
} catch (e) {
|
||||
console.error('Duplicate failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
|
||||
const copyNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
clipboardRef.current = nodeId;
|
||||
} catch (e) {
|
||||
console.error('Copy failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, onClose]);
|
||||
|
||||
const pasteNode = useCallback(() => {
|
||||
const sourceId = clipboardRef.current;
|
||||
if (!sourceId) return;
|
||||
try {
|
||||
const targetParent = nodeId || 'ROOT';
|
||||
const tree = query.node(sourceId).toNodeTree();
|
||||
actions.addNodeTree(tree, targetParent);
|
||||
} catch (e) {
|
||||
console.error('Paste failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, onClose]);
|
||||
|
||||
const moveUp = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
const parent = query.node(parentId).get();
|
||||
const children = parent.data.nodes || [];
|
||||
const idx = children.indexOf(nodeId);
|
||||
if (idx > 0) {
|
||||
actions.move(nodeId, parentId, idx - 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Move up failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
|
||||
const moveDown = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
const parent = query.node(parentId).get();
|
||||
const children = parent.data.nodes || [];
|
||||
const idx = children.indexOf(nodeId);
|
||||
if (idx < children.length - 1) {
|
||||
actions.move(nodeId, parentId, idx + 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Move down failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
|
||||
const selectParent = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
const parentId = getParentId();
|
||||
if (parentId) {
|
||||
actions.selectNode(parentId);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, getParentId, onClose]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
actions.delete(nodeId);
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, onClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const isRoot = nodeId === 'ROOT' || !nodeId;
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: 'Duplicate',
|
||||
shortcut: 'Ctrl+D',
|
||||
action: duplicate,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
shortcut: 'Ctrl+C',
|
||||
action: copyNode,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Paste',
|
||||
shortcut: 'Ctrl+V',
|
||||
action: pasteNode,
|
||||
disabled: !clipboardRef.current,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: 'Move Up',
|
||||
action: moveUp,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Move Down',
|
||||
action: moveDown,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Select Parent',
|
||||
action: selectParent,
|
||||
disabled: isRoot,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
shortcut: 'Del',
|
||||
action: deleteNode,
|
||||
danger: true,
|
||||
disabled: isRoot,
|
||||
},
|
||||
];
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
const adjustedX = Math.min(x, window.innerWidth - 200);
|
||||
const adjustedY = Math.min(y, window.innerHeight - items.length * 34 - 10);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: adjustedY,
|
||||
left: adjustedX,
|
||||
zIndex: 10000,
|
||||
minWidth: 180,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
padding: '4px 0',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.label}>
|
||||
<button
|
||||
onClick={item.disabled ? undefined : item.action}
|
||||
disabled={item.disabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '7px 12px',
|
||||
fontSize: 12,
|
||||
color: item.disabled
|
||||
? 'var(--color-text-dim)'
|
||||
: item.danger
|
||||
? 'var(--color-danger)'
|
||||
: 'var(--color-text)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: item.disabled ? 'default' : 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!item.disabled) {
|
||||
(e.target as HTMLElement).style.background = 'var(--color-bg-hover)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
marginLeft: 16,
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{item.dividerAfter && (
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: 'var(--color-border)',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
249
craft/src/panels/left/AssetsPanel.tsx
Normal file
249
craft/src/panels/left/AssetsPanel.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAssets } from '../../hooks/useAssets';
|
||||
|
||||
export const AssetsPanel: React.FC = () => {
|
||||
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadAssets();
|
||||
}, [loadAssets]);
|
||||
|
||||
const handleFileSelect = useCallback(async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await uploadAsset(files[i]);
|
||||
}
|
||||
}, [uploadAsset]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}, [handleFileSelect]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const copyUrl = useCallback((url: string) => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedUrl(url);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isImage = (type: string) =>
|
||||
type.startsWith('image/') || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(type);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{/* Upload button */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: loading ? 'var(--color-bg-active)' : 'var(--color-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Uploading...' : 'Upload File'}
|
||||
</button>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
style={{
|
||||
padding: 20,
|
||||
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
textAlign: 'center',
|
||||
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
||||
fontSize: 11,
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
Drop files here to upload
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-danger)',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
{assets.length === 0 && !loading && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 20,
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
No assets uploaded yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color var(--transition-fast)',
|
||||
}}
|
||||
onClick={() => copyUrl(asset.url)}
|
||||
title={`Click to copy URL: ${asset.url}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--color-bg-base)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isImage(asset.type) ? (
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={asset.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: 'var(--color-text-dim)',
|
||||
}}
|
||||
>
|
||||
📄
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{copiedUrl === asset.url ? 'Copied!' : asset.name}
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteAsset(asset.name);
|
||||
}}
|
||||
title="Delete asset"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
color: '#fff',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && assets.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
211
craft/src/panels/left/BlocksPanel.tsx
Normal file
211
craft/src/panels/left/BlocksPanel.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { Container } from '../../components/layout/Container';
|
||||
import { Section } from '../../components/layout/Section';
|
||||
import { ColumnLayout } from '../../components/layout/ColumnLayout';
|
||||
import { BackgroundSection } from '../../components/layout/BackgroundSection';
|
||||
import { Heading } from '../../components/basic/Heading';
|
||||
import { TextBlock } from '../../components/basic/TextBlock';
|
||||
import { ButtonLink } from '../../components/basic/ButtonLink';
|
||||
import { Logo } from '../../components/basic/Logo';
|
||||
import { Menu } from '../../components/basic/Menu';
|
||||
import { Footer } from '../../components/basic/Footer';
|
||||
import { Divider } from '../../components/basic/Divider';
|
||||
import { Spacer } from '../../components/basic/Spacer';
|
||||
import { Icon } from '../../components/basic/Icon';
|
||||
import { HtmlBlock } from '../../components/basic/HtmlBlock';
|
||||
import { ImageBlock } from '../../components/media/ImageBlock';
|
||||
import { VideoBlock } from '../../components/media/VideoBlock';
|
||||
import { MapEmbed } from '../../components/media/MapEmbed';
|
||||
import { HeroSimple } from '../../components/sections/HeroSimple';
|
||||
import { FeaturesGrid } from '../../components/sections/FeaturesGrid';
|
||||
import { CallToAction } from '../../components/sections/CallToAction';
|
||||
import { Countdown } from '../../components/sections/Countdown';
|
||||
import { Testimonials } from '../../components/sections/Testimonials';
|
||||
import { Accordion } from '../../components/sections/Accordion';
|
||||
import { Tabs } from '../../components/sections/Tabs';
|
||||
import { PricingTable } from '../../components/sections/PricingTable';
|
||||
import { Gallery } from '../../components/sections/Gallery';
|
||||
import { ContentSlider } from '../../components/sections/ContentSlider';
|
||||
import { NumberCounter } from '../../components/sections/NumberCounter';
|
||||
import { FormContainer } from '../../components/forms/FormContainer';
|
||||
import { InputField } from '../../components/forms/InputField';
|
||||
import { TextareaField } from '../../components/forms/TextareaField';
|
||||
import { FormButton } from '../../components/forms/FormButton';
|
||||
import { ContactForm } from '../../components/forms/ContactForm';
|
||||
import { SubscribeForm } from '../../components/forms/SubscribeForm';
|
||||
import { StarRating } from '../../components/basic/StarRating';
|
||||
import { SocialLinks } from '../../components/basic/SocialLinks';
|
||||
|
||||
interface BlockDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactElement;
|
||||
}
|
||||
|
||||
interface CategoryDef {
|
||||
id: string;
|
||||
label: string;
|
||||
blocks: BlockDef[];
|
||||
}
|
||||
|
||||
const categories: CategoryDef[] = [
|
||||
{
|
||||
id: 'basic',
|
||||
label: 'Basic',
|
||||
blocks: [
|
||||
{ id: 'heading', label: 'Heading', icon: 'fa-header',
|
||||
component: <Heading text="Your Heading" level="h2" style={{ fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px' }} /> },
|
||||
{ id: 'text', label: 'Text', icon: 'fa-paragraph',
|
||||
component: <TextBlock text="Add your text here. Click to edit." style={{ fontSize: '16px', lineHeight: '1.6', color: '#4b5563', fontFamily: 'Inter, sans-serif' }} /> },
|
||||
{ id: 'button', label: 'Button', icon: 'fa-square',
|
||||
component: <ButtonLink text="Click Me" href="#" style={{ display: 'inline-block', padding: '14px 32px', background: '#3b82f6', color: '#ffffff', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '16px', fontFamily: 'Inter, sans-serif' }} /> },
|
||||
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', component: <Logo /> },
|
||||
{ id: 'menu', label: 'Menu', icon: 'fa-bars', component: <Menu /> },
|
||||
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', component: <Footer /> },
|
||||
{ id: 'divider', label: 'Divider', icon: 'fa-minus', component: <Divider /> },
|
||||
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', component: <Spacer /> },
|
||||
{ id: 'icon', label: 'Icon', icon: 'fa-star', component: <Icon icon="fa-star" size="48px" color="#3b82f6" /> },
|
||||
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', component: <StarRating /> },
|
||||
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', component: <SocialLinks /> },
|
||||
{ id: 'html-block', label: 'HTML', icon: 'fa-code', component: <HtmlBlock code="<div>Custom HTML</div>" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
label: 'Layout',
|
||||
blocks: [
|
||||
{ id: 'section', label: 'Section', icon: 'fa-window-maximize',
|
||||
component: <Section style={{ padding: '60px 20px', backgroundColor: '#ffffff' }} /> },
|
||||
{ id: 'container', label: 'Container', icon: 'fa-square-o',
|
||||
component: <Container style={{ padding: '20px', minHeight: '100px' }} /> },
|
||||
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop',
|
||||
component: <ColumnLayout columns={1} split="100" /> },
|
||||
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large',
|
||||
component: <ColumnLayout columns={2} split="50-50" /> },
|
||||
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={3} split="33-33-33" /> },
|
||||
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={4} split="25-25-25-25" /> },
|
||||
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={5} split="20-20-20-20-20" /> },
|
||||
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={6} split="16-16-16-16-16-16" /> },
|
||||
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns',
|
||||
component: <ColumnLayout columns={2} split="30-70" /> },
|
||||
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns',
|
||||
component: <ColumnLayout columns={2} split="70-30" /> },
|
||||
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', component: <BackgroundSection /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sections',
|
||||
label: 'Sections',
|
||||
blocks: [
|
||||
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', component: <HeroSimple /> },
|
||||
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', component: <FeaturesGrid /> },
|
||||
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', component: <CallToAction /> },
|
||||
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', component: <Accordion /> },
|
||||
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', component: <Tabs /> },
|
||||
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', component: <PricingTable /> },
|
||||
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', component: <Gallery /> },
|
||||
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o',
|
||||
component: <Countdown targetDate={new Date(Date.now() + 30 * 86400000).toISOString()} /> },
|
||||
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left',
|
||||
component: <Testimonials testimonials={[{ quote: "Great service!", name: "John", title: "CEO", rating: 5 }]} layout="grid" columns={3} /> },
|
||||
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', component: <ContentSlider /> },
|
||||
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', component: <NumberCounter /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
label: 'Media',
|
||||
blocks: [
|
||||
{ id: 'image', label: 'Image', icon: 'fa-image',
|
||||
component: <ImageBlock src="" alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
|
||||
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
|
||||
component: <VideoBlock videoUrl="" isBackground={false} /> },
|
||||
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
|
||||
component: <MapEmbed address="New York, NY" zoom={14} height="400px" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'forms',
|
||||
label: 'Forms',
|
||||
blocks: [
|
||||
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', component: <ContactForm /> },
|
||||
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', component: <SubscribeForm /> },
|
||||
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', component: <FormContainer /> },
|
||||
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', component: <InputField /> },
|
||||
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', component: <TextareaField /> },
|
||||
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', component: <FormButton /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const BlocksPanel: React.FC = () => {
|
||||
const { connectors, actions, query } = useEditor();
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
categories.forEach((cat, index) => {
|
||||
initial[cat.id] = index !== 0; // First category open
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{categories.map((category) => {
|
||||
const isCollapsed = collapsed[category.id];
|
||||
return (
|
||||
<div key={category.id} className="block-category">
|
||||
<div
|
||||
className={`block-category-header ${isCollapsed ? 'collapsed' : ''}`}
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
>
|
||||
<span>{category.label}</span>
|
||||
<i className="fa fa-chevron-down chevron" />
|
||||
</div>
|
||||
<div className={`block-category-items ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="block-grid">
|
||||
{category.blocks.map((block) => (
|
||||
<div
|
||||
key={block.id}
|
||||
className="block-item"
|
||||
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
|
||||
onDoubleClick={() => {
|
||||
try {
|
||||
const serialized = JSON.parse(query.serialize());
|
||||
const nodeIds = Object.keys(serialized);
|
||||
let canvasId = 'ROOT';
|
||||
for (const nodeId of nodeIds) {
|
||||
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
|
||||
canvasId = nodeId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
|
||||
actions.addNodeTree(tree, canvasId);
|
||||
} catch (e) {
|
||||
console.error('Failed to add block:', e);
|
||||
}
|
||||
}}
|
||||
title={`Drag or double-click to add ${block.label}`}
|
||||
>
|
||||
<i className={`fa ${block.icon} block-item-icon`} />
|
||||
<span className="block-item-label">{block.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
137
craft/src/panels/left/LayersPanel.tsx
Normal file
137
craft/src/panels/left/LayersPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
|
||||
interface LayerNodeProps {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
const { node, selectedId, actions } = useEditor((state) => {
|
||||
const n = state.nodes[nodeId];
|
||||
const selectedIds = state.events.selected;
|
||||
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
|
||||
return {
|
||||
node: n,
|
||||
selectedId: selId,
|
||||
};
|
||||
});
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
actions.selectNode(nodeId);
|
||||
}, [actions, nodeId]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const isSelected = selectedId === nodeId;
|
||||
const nodeType = node.data.type;
|
||||
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
|
||||
? (nodeType as any).resolvedName
|
||||
: typeof nodeType === 'string' ? nodeType : undefined;
|
||||
const displayName = node.data.displayName || resolvedName || 'Component';
|
||||
const childNodeIds: string[] = node.data.nodes || [];
|
||||
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
||||
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
||||
const isRoot = nodeId === 'ROOT';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '5px 8px',
|
||||
paddingLeft: `${8 + depth * 16}px`,
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
color: isSelected ? 'var(--color-accent)' : 'var(--color-text)',
|
||||
background: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
borderLeft: isSelected ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Indentation indicator */}
|
||||
{allChildren.length > 0 && (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)' }}>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
{allChildren.length === 0 && (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Component type icon and name */}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{isRoot ? 'Canvas (Root)' : displayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Render children */}
|
||||
{allChildren.map((childId) => (
|
||||
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LayersPanel: React.FC = () => {
|
||||
const { nodeIds } = useEditor((state) => {
|
||||
return {
|
||||
nodeIds: Object.keys(state.nodes),
|
||||
};
|
||||
});
|
||||
|
||||
const hasRoot = nodeIds.includes('ROOT');
|
||||
|
||||
if (!hasRoot) {
|
||||
return (
|
||||
<div className="panel-placeholder">
|
||||
No content on canvas
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '-12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: 'var(--color-text-muted)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
Component Tree
|
||||
</div>
|
||||
<LayerNode nodeId="ROOT" depth={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
craft/src/panels/left/LeftPanel.tsx
Normal file
40
craft/src/panels/left/LeftPanel.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlocksPanel } from './BlocksPanel';
|
||||
import { PagesPanel } from './PagesPanel';
|
||||
import { LayersPanel } from './LayersPanel';
|
||||
import { AssetsPanel } from './AssetsPanel';
|
||||
|
||||
type LeftTab = 'blocks' | 'pages' | 'layers' | 'assets';
|
||||
|
||||
export const LeftPanel: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<LeftTab>('blocks');
|
||||
|
||||
const tabs: { id: LeftTab; label: string }[] = [
|
||||
{ id: 'blocks', label: 'Blocks' },
|
||||
{ id: 'pages', label: 'Pages' },
|
||||
{ id: 'layers', label: 'Layers' },
|
||||
{ id: 'assets', label: 'Assets' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="panel-left">
|
||||
<div className="panel-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`panel-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="panel-content">
|
||||
{activeTab === 'blocks' && <BlocksPanel />}
|
||||
{activeTab === 'pages' && <PagesPanel />}
|
||||
{activeTab === 'layers' && <LayersPanel />}
|
||||
{activeTab === 'assets' && <AssetsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
470
craft/src/panels/left/PagesPanel.tsx
Normal file
470
craft/src/panels/left/PagesPanel.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
|
||||
export const PagesPanel: React.FC = () => {
|
||||
const {
|
||||
pages,
|
||||
activePageId,
|
||||
isEditingHeader,
|
||||
isEditingFooter,
|
||||
switchPage,
|
||||
editHeader,
|
||||
editFooter,
|
||||
addPage,
|
||||
deletePage,
|
||||
renamePage,
|
||||
} = usePages();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newSlug, setNewSlug] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editSlug, setEditSlug] = useState('');
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newName.trim()) return;
|
||||
addPage(newName.trim(), newSlug.trim());
|
||||
setNewName('');
|
||||
setNewSlug('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleRename = (pageId: string) => {
|
||||
if (!editName.trim()) return;
|
||||
renamePage(pageId, editName.trim(), editSlug.trim());
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleDelete = (pageId: string) => {
|
||||
deletePage(pageId);
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
const startEditing = (page: { id: string; name: string; slug: string }) => {
|
||||
setEditingId(page.id);
|
||||
setEditName(page.name);
|
||||
setEditSlug(page.slug);
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
const autoSlug = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
/* ---------- Zone button style ---------- */
|
||||
const zoneButtonStyle = (isActive: boolean): React.CSSProperties => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: isActive ? '#f59e0b' : '#fbbf24',
|
||||
background: isActive ? 'rgba(245, 158, 11, 0.15)' : 'rgba(245, 158, 11, 0.06)',
|
||||
border: `1px solid ${isActive ? 'rgba(245, 158, 11, 0.5)' : 'rgba(245, 158, 11, 0.2)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
textAlign: 'left' as const,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* Header/Footer zone buttons */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={editHeader}
|
||||
style={zoneButtonStyle(isEditingHeader)}
|
||||
>
|
||||
<i className="fa fa-window-maximize" style={{ fontSize: 13 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>Edit Header</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
|
||||
Appears on all pages
|
||||
</div>
|
||||
</div>
|
||||
{isEditingHeader && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
background: 'rgba(245, 158, 11, 0.25)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={editFooter}
|
||||
style={zoneButtonStyle(isEditingFooter)}
|
||||
>
|
||||
<i className="fa fa-window-minimize" style={{ fontSize: 13 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>Edit Footer</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
|
||||
Appears on all pages
|
||||
</div>
|
||||
</div>
|
||||
{isEditingFooter && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
background: 'rgba(245, 158, 11, 0.25)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section label for pages */}
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: 'var(--color-text-dim)',
|
||||
padding: '0 2px',
|
||||
}}>
|
||||
Pages
|
||||
</div>
|
||||
|
||||
{/* Page list */}
|
||||
{pages.map((page) => (
|
||||
<div key={page.id}>
|
||||
{editingId === page.id ? (
|
||||
/* Editing mode */
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-accent)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => {
|
||||
setEditName(e.target.value);
|
||||
setEditSlug(autoSlug(e.target.value));
|
||||
}}
|
||||
placeholder="Page name"
|
||||
className="control-input"
|
||||
style={{ fontSize: 12 }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(page.id);
|
||||
if (e.key === 'Escape') setEditingId(null);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editSlug}
|
||||
onChange={(e) => setEditSlug(e.target.value)}
|
||||
placeholder="page-slug"
|
||||
className="control-input"
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => handleRename(page.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: 'var(--color-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : deleteConfirmId === page.id ? (
|
||||
/* Delete confirmation */
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
|
||||
Delete "{page.name}"?
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => handleDelete(page.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: 'var(--color-danger)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal page item */
|
||||
<div
|
||||
onClick={() => switchPage(page.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 10px',
|
||||
background:
|
||||
page.id === activePageId
|
||||
? 'var(--color-accent-subtle)'
|
||||
: 'var(--color-bg-elevated)',
|
||||
border: `1px solid ${page.id === activePageId ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: page.id === activePageId ? 600 : 500,
|
||||
color:
|
||||
page.id === activePageId
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{page.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
/{page.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', gap: 4, flexShrink: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => startEditing(page)}
|
||||
title="Rename"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
{pages.length > 1 && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(page.id)}
|
||||
title="Delete"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add page section */}
|
||||
{isAdding ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
setNewName(e.target.value);
|
||||
setNewSlug(autoSlug(e.target.value));
|
||||
}}
|
||||
placeholder="Page name"
|
||||
className="control-input"
|
||||
style={{ fontSize: 12 }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
if (e.key === 'Escape') {
|
||||
setIsAdding(false);
|
||||
setNewName('');
|
||||
setNewSlug('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(e.target.value)}
|
||||
placeholder="page-slug"
|
||||
className="control-input"
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!newName.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: newName.trim() ? 'var(--color-accent)' : 'var(--color-bg-active)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: newName.trim() ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
Add Page
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewName('');
|
||||
setNewSlug('');
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-accent)',
|
||||
background: 'var(--color-accent-subtle)',
|
||||
border: '1px dashed var(--color-accent)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
+ Add Page
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
craft/src/panels/right/GuidedStyles.tsx
Normal file
155
craft/src/panels/right/GuidedStyles.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { componentResolver } from '../../components/resolver';
|
||||
import { SiteDesignPanel } from './SiteDesignPanel';
|
||||
import {
|
||||
TextStylePanel,
|
||||
ButtonStylePanel,
|
||||
ImageStylePanel,
|
||||
ContainerStylePanel,
|
||||
HeroStylePanel,
|
||||
NavStylePanel,
|
||||
MediaStylePanel,
|
||||
FormStylePanel,
|
||||
SocialStylePanel,
|
||||
SectionTypePanel,
|
||||
PricingStylePanel,
|
||||
BackgroundSectionStylePanel,
|
||||
GenericPropsEditor,
|
||||
} from './styles';
|
||||
|
||||
/* ================================================================
|
||||
IMPORTANT: None of these panels use useNode().
|
||||
All prop mutations go through useEditor().actions.setProp(nodeId, ...)
|
||||
so they work from outside the node's render tree.
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
Main GuidedStyles component
|
||||
================================================================ */
|
||||
|
||||
export const GuidedStyles: React.FC = () => {
|
||||
const resolverMap = componentResolver as Record<string, any>;
|
||||
|
||||
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
|
||||
const currentNodeId = state.events.selected
|
||||
? Array.from(state.events.selected)[0]
|
||||
: undefined;
|
||||
let selectedType: string | null = null;
|
||||
let nodeProps: Record<string, any> = {};
|
||||
let resolvedName: string | null = null;
|
||||
|
||||
if (currentNodeId) {
|
||||
const node = state.nodes[currentNodeId];
|
||||
if (node) {
|
||||
selectedType = node.data.displayName || node.data.name || null;
|
||||
nodeProps = node.data.props || {};
|
||||
const nodeType = node.data.type as any;
|
||||
if (nodeType && typeof nodeType === 'object' && nodeType.resolvedName) {
|
||||
resolvedName = nodeType.resolvedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selected: currentNodeId || null,
|
||||
selectedType,
|
||||
nodeProps,
|
||||
resolvedName,
|
||||
};
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
return <SiteDesignPanel />;
|
||||
}
|
||||
|
||||
// Determine which panel to show based on displayName
|
||||
const typeName = selectedType || '';
|
||||
|
||||
// ---- Type classification (covers ALL 40 component display names) ----
|
||||
const isText = /^heading$|^text$/i.test(typeName);
|
||||
const isButton = /^button$/i.test(typeName);
|
||||
const isImage = /^image$/i.test(typeName);
|
||||
const isBgSection = /^background section$/i.test(typeName);
|
||||
const isContainer = /^container$|^section$|^columns$|^header zone$|^footer zone$/i.test(typeName);
|
||||
const isHero = /hero/i.test(typeName);
|
||||
const isNav = /^menu$|^logo$|^navbar$|^footer$/i.test(typeName);
|
||||
const isMedia = /^video$|^gallery$|^map$|^content slider$/i.test(typeName);
|
||||
const isForm = /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i.test(typeName);
|
||||
const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName);
|
||||
const isPricing = /^pricing/i.test(typeName);
|
||||
const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName);
|
||||
// Utility types that need minimal controls
|
||||
const isUtility = /^divider$|^spacer$|^html$/i.test(typeName);
|
||||
|
||||
// Icon for the type badge
|
||||
const typeIcon = isText ? 'fa-font'
|
||||
: isButton ? 'fa-hand-pointer-o'
|
||||
: isImage ? 'fa-image'
|
||||
: isBgSection ? 'fa-picture-o'
|
||||
: isContainer ? 'fa-object-group'
|
||||
: isHero ? 'fa-star'
|
||||
: isNav ? 'fa-bars'
|
||||
: isMedia ? 'fa-play-circle'
|
||||
: isForm ? 'fa-wpforms'
|
||||
: isSocial ? 'fa-share-alt'
|
||||
: isSection ? 'fa-th-large'
|
||||
: isUtility ? 'fa-ellipsis-h'
|
||||
: 'fa-cube';
|
||||
|
||||
return (
|
||||
<div className="guided-styles">
|
||||
{/* Component type badge */}
|
||||
<div className="guided-section guided-type-header">
|
||||
<span className="guided-type-badge">
|
||||
<i className={`fa ${typeIcon}`} />
|
||||
{' '}{typeName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* TEXT */}
|
||||
{isText && <TextStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* BUTTON */}
|
||||
{isButton && <ButtonStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* IMAGE */}
|
||||
{isImage && <ImageStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* BACKGROUND SECTION */}
|
||||
{isBgSection && <BackgroundSectionStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* CONTAINER / SECTION / COLUMNS */}
|
||||
{isContainer && <ContainerStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* HERO */}
|
||||
{isHero && <HeroStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* NAV / MENU / LOGO / FOOTER */}
|
||||
{isNav && <NavStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* MEDIA (Video, Gallery, Map, Slider) */}
|
||||
{isMedia && <MediaStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* FORM (Form, Input, Textarea, Subscribe, Contact, FormButton, Search) */}
|
||||
{isForm && <FormStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* SOCIAL / ICON / STAR RATING */}
|
||||
{isSocial && <SocialStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* PRICING TABLE */}
|
||||
{isPricing && <PricingStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */}
|
||||
{isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||
|
||||
{/* UTILITY (Divider, Spacer, HTML) -- use generic but it works well for these */}
|
||||
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||
|
||||
{/* FALLBACK: Anything not matched above */}
|
||||
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && (
|
||||
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
craft/src/panels/right/RightPanel.tsx
Normal file
19
craft/src/panels/right/RightPanel.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { GuidedStyles } from './GuidedStyles';
|
||||
|
||||
/**
|
||||
* Right panel -- shows component settings when an element is selected,
|
||||
* or Site Design Tokens (including Head code) when nothing is selected.
|
||||
*/
|
||||
export const RightPanel: React.FC = () => {
|
||||
return (
|
||||
<div className="panel-right">
|
||||
<div className="panel-tabs">
|
||||
<button className="panel-tab active">Styles</button>
|
||||
</div>
|
||||
<div className="panel-content">
|
||||
<GuidedStyles />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
362
craft/src/panels/right/SiteDesignPanel.tsx
Normal file
362
craft/src/panels/right/SiteDesignPanel.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||
import { FONT_FAMILIES } from '../../constants/presets';
|
||||
|
||||
type DesignTab = 'basic' | 'advanced';
|
||||
|
||||
/* ---------- Color picker with preset swatches ---------- */
|
||||
|
||||
const COLOR_SWATCHES = [
|
||||
'#3b82f6', '#8b5cf6', '#10b981', '#ef4444',
|
||||
'#f59e0b', '#ec4899', '#06b6d4', '#f97316',
|
||||
];
|
||||
|
||||
const NEUTRAL_SWATCHES = [
|
||||
'#ffffff', '#f9fafb', '#e5e7eb', '#9ca3af',
|
||||
'#6b7280', '#374151', '#1f2937', '#111827',
|
||||
];
|
||||
|
||||
interface ColorFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
swatches?: string[];
|
||||
}
|
||||
|
||||
const ColorField: React.FC<ColorFieldProps> = ({ label, value, onChange, swatches = COLOR_SWATCHES }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">{label}</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
padding: 0,
|
||||
border: '2px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 11, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
|
||||
{swatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => onChange(c)}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: value === c ? '2px solid var(--color-accent)' : '2px solid var(--color-border)',
|
||||
background: c,
|
||||
cursor: 'pointer',
|
||||
boxShadow: value === c ? '0 0 0 2px var(--color-accent)' : 'none',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Font dropdown ---------- */
|
||||
|
||||
interface FontFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const FontField: React.FC<FontFieldProps> = ({ label, value, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">{label}</label>
|
||||
<select
|
||||
className="control-select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Border radius presets ---------- */
|
||||
|
||||
const BORDER_RADIUS_PRESETS = [
|
||||
{ label: '0', value: '0' },
|
||||
{ label: '4px', value: '4px' },
|
||||
{ label: '8px', value: '8px' },
|
||||
{ label: '12px', value: '12px' },
|
||||
{ label: '16px', value: '16px' },
|
||||
];
|
||||
|
||||
interface RadiusFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const RadiusField: React.FC<RadiusFieldProps> = ({ label, value, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">{label}</label>
|
||||
<div className="preset-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
{BORDER_RADIUS_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`preset-btn ${value === p.value ? 'active' : ''}`}
|
||||
onClick={() => onChange(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Nav style toggle ---------- */
|
||||
|
||||
interface NavStyleFieldProps {
|
||||
value: 'light' | 'dark';
|
||||
onChange: (value: 'light' | 'dark') => void;
|
||||
}
|
||||
|
||||
const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">Nav Style</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{(['light', 'dark'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`preset-btn ${value === s ? 'active' : ''}`}
|
||||
style={{ flex: 1, textTransform: 'capitalize' }}
|
||||
onClick={() => onChange(s)}
|
||||
>
|
||||
{s === 'light' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Main SiteDesignPanel ---------- */
|
||||
|
||||
export const SiteDesignPanel: React.FC = () => {
|
||||
const { design, updateDesign, resetToDefaults } = useSiteDesign();
|
||||
const [tab, setTab] = useState<DesignTab>('basic');
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<i className="fa fa-paint-brush" style={{ color: 'var(--color-accent)', fontSize: 14 }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Site Design Tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||
{(['basic', 'advanced'] as DesignTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: tab === t ? '#fff' : 'var(--color-text-muted)',
|
||||
background: tab === t ? 'var(--color-accent)' : 'var(--color-bg-elevated)',
|
||||
border: tab === t ? '1px solid var(--color-accent)' : '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Basic tab */}
|
||||
{tab === 'basic' && (
|
||||
<>
|
||||
<ColorField
|
||||
label="Primary Color"
|
||||
value={design.primaryColor}
|
||||
onChange={(v) => updateDesign({ primaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Secondary Color"
|
||||
value={design.secondaryColor}
|
||||
onChange={(v) => updateDesign({ secondaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent Color"
|
||||
value={design.accentColor}
|
||||
onChange={(v) => updateDesign({ accentColor: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Heading Font"
|
||||
value={design.headingFont}
|
||||
onChange={(v) => updateDesign({ headingFont: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Body Font"
|
||||
value={design.bodyFont}
|
||||
onChange={(v) => updateDesign({ bodyFont: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Link Color"
|
||||
value={design.linkColor}
|
||||
onChange={(v) => updateDesign({ linkColor: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Advanced tab */}
|
||||
{tab === 'advanced' && (
|
||||
<>
|
||||
<ColorField
|
||||
label="Primary Color"
|
||||
value={design.primaryColor}
|
||||
onChange={(v) => updateDesign({ primaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Secondary Color"
|
||||
value={design.secondaryColor}
|
||||
onChange={(v) => updateDesign({ secondaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent Color"
|
||||
value={design.accentColor}
|
||||
onChange={(v) => updateDesign({ accentColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Success Color"
|
||||
value={design.successColor}
|
||||
onChange={(v) => updateDesign({ successColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Warning Color"
|
||||
value={design.warningColor}
|
||||
onChange={(v) => updateDesign({ warningColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Error Color"
|
||||
value={design.errorColor}
|
||||
onChange={(v) => updateDesign({ errorColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Background Color"
|
||||
value={design.backgroundColor}
|
||||
onChange={(v) => updateDesign({ backgroundColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<ColorField
|
||||
label="Text Color"
|
||||
value={design.textColor}
|
||||
onChange={(v) => updateDesign({ textColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<ColorField
|
||||
label="Muted Text Color"
|
||||
value={design.mutedTextColor}
|
||||
onChange={(v) => updateDesign({ mutedTextColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<ColorField
|
||||
label="Border Color"
|
||||
value={design.borderColor}
|
||||
onChange={(v) => updateDesign({ borderColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<FontField
|
||||
label="Heading Font"
|
||||
value={design.headingFont}
|
||||
onChange={(v) => updateDesign({ headingFont: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Body Font"
|
||||
value={design.bodyFont}
|
||||
onChange={(v) => updateDesign({ bodyFont: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Button Font"
|
||||
value={design.buttonFont}
|
||||
onChange={(v) => updateDesign({ buttonFont: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Link Color"
|
||||
value={design.linkColor}
|
||||
onChange={(v) => updateDesign({ linkColor: v })}
|
||||
/>
|
||||
<RadiusField
|
||||
label="Default Border Radius"
|
||||
value={design.borderRadius}
|
||||
onChange={(v) => updateDesign({ borderRadius: v })}
|
||||
/>
|
||||
<RadiusField
|
||||
label="Button Radius"
|
||||
value={design.buttonRadius}
|
||||
onChange={(v) => updateDesign({ buttonRadius: v })}
|
||||
/>
|
||||
<NavStyleField
|
||||
value={design.navStyle}
|
||||
onChange={(v) => updateDesign({ navStyle: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--color-border)' }}>
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
<p style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
textAlign: 'center',
|
||||
margin: '8px 0 0',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
Design tokens are saved with your project and applied to new components.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
GradientSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
uploadToWhp,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- BACKGROUND SECTION ---------- */
|
||||
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp('bgImage', url);
|
||||
}, [setProp]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background Image */}
|
||||
<CollapsibleSection title="Background Image">
|
||||
{nodeProps.bgImage && (
|
||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
|
||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste image URL..."
|
||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Background Color */}
|
||||
<CollapsibleSection title="Background Color">
|
||||
<ColorPickerField label="Background" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Overlay */}
|
||||
<CollapsibleSection title="Overlay">
|
||||
<ColorPickerField label="Overlay Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}%</label>
|
||||
<input type="range" min={0} max={100} value={Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}
|
||||
onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value) / 100)} style={{ width: '100%' }} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Layout */}
|
||||
<CollapsibleSection title="Layout" defaultOpen={false}>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Inner Max Width</label>
|
||||
<input type="text" value={nodeProps.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp('innerMaxWidth', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
88
craft/src/panels/right/styles/ButtonStylePanel.tsx
Normal file
88
craft/src/panels/right/styles/ButtonStylePanel.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
RADIUS_PRESETS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
autoTextColor,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- BUTTON ---------- */
|
||||
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
const setButtonColor = useCallback(
|
||||
(bgColor: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = {
|
||||
...props.style,
|
||||
backgroundColor: bgColor,
|
||||
color: autoTextColor(bgColor),
|
||||
};
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Button Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={BG_COLORS}
|
||||
activeValue={style.backgroundColor as string}
|
||||
onSelect={setButtonColor}
|
||||
/>
|
||||
</div>
|
||||
<TextInputField
|
||||
label="Button Text"
|
||||
value={nodeProps.text || ''}
|
||||
placeholder="Click Me"
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.text = v; });
|
||||
}}
|
||||
/>
|
||||
<TextInputField
|
||||
label="Link URL"
|
||||
value={nodeProps.href || ''}
|
||||
placeholder="https://..."
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.href = v; });
|
||||
}}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={SPACING_PRESETS}
|
||||
activeValue={style.padding as string}
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
80
craft/src/panels/right/styles/ContainerStylePanel.tsx
Normal file
80
craft/src/panels/right/styles/ContainerStylePanel.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
GradientSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- CONTAINER / SECTION ---------- */
|
||||
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={BG_COLORS}
|
||||
activeValue={style.backgroundColor as string}
|
||||
onSelect={(v) => setPropStyle('backgroundColor', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background Gradient</SectionLabel>
|
||||
<GradientSwatchGrid
|
||||
activeValue={style.background as string}
|
||||
onSelect={(v) => setPropStyle('background', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={SPACING_PRESETS}
|
||||
activeValue={style.padding as string}
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
|
||||
onClick={() => setPropStyle('textAlign', a)}
|
||||
title={a}
|
||||
>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
139
craft/src/panels/right/styles/FormStylePanel.tsx
Normal file
139
craft/src/panels/right/styles/FormStylePanel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- FORM ---------- */
|
||||
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Form action/method */}
|
||||
{nodeProps.action !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Form Action URL</label>
|
||||
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.method !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Method</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['GET', 'POST'].map((m) => (
|
||||
<button key={m} onClick={() => setProp('method', m)} style={btnActiveStyle(nodeProps.method === m)}>{m}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field props */}
|
||||
{nodeProps.label !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Label</label>
|
||||
<input type="text" value={nodeProps.label || ''} onChange={(e) => setProp('label', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.placeholder !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input type="text" value={nodeProps.placeholder || ''} onChange={(e) => setProp('placeholder', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.name !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Field Name</label>
|
||||
<input type="text" value={nodeProps.name || ''} onChange={(e) => setProp('name', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.type !== undefined && typeof nodeProps.type === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Input Type</label>
|
||||
<select value={nodeProps.type} onChange={(e) => setProp('type', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
{['text', 'email', 'password', 'number', 'tel', 'url'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.required !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.required || false} onChange={(e) => setProp('required', e.target.checked)} />
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button text */}
|
||||
{nodeProps.text !== undefined && nodeProps.label === undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscribe form props */}
|
||||
{nodeProps.buttonText !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.placeholderText !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Placeholder Text</label>
|
||||
<input type="text" value={nodeProps.placeholderText || ''} onChange={(e) => setProp('placeholderText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.successMessage !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Success Message</label>
|
||||
<input type="text" value={nodeProps.successMessage || ''} onChange={(e) => setProp('successMessage', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
251
craft/src/panels/right/styles/GenericPropsEditor.tsx
Normal file
251
craft/src/panels/right/styles/GenericPropsEditor.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
||||
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
||||
selectedId, nodeProps, typeName,
|
||||
}) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
|
||||
const setPropValue = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setStyleValue = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
// Categorize all props
|
||||
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
|
||||
const colorProps = allProps.filter(([key, val]) => typeof val === 'string' && /color/i.test(key));
|
||||
const boolProps = allProps.filter(([_, val]) => typeof val === 'boolean');
|
||||
const numberProps = allProps.filter(([key, val]) => typeof val === 'number' && !/color/i.test(key));
|
||||
const stringProps = allProps.filter(([key, val]) => typeof val === 'string' && !/color/i.test(key));
|
||||
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* String props */}
|
||||
{stringProps.length > 0 && (
|
||||
<CollapsibleSection title="Properties">
|
||||
{stringProps.map(([key, val]) => {
|
||||
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
|
||||
const isLong = String(val).length > 60 || key === 'description' || key === 'text' || key === 'content';
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
{isLong ? (
|
||||
<textarea value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Number props */}
|
||||
{numberProps.length > 0 && (
|
||||
<CollapsibleSection title="Numbers" defaultOpen={true}>
|
||||
{numberProps.map(([key, val]) => (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||
<input type="number" value={val as number} onChange={(e) => setPropValue(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Boolean props */}
|
||||
{boolProps.length > 0 && (
|
||||
<CollapsibleSection title="Options" defaultOpen={true}>
|
||||
{boolProps.map(([key, val]) => (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={val as boolean} onChange={(e) => setPropValue(key, e.target.checked)} />
|
||||
{key.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Color props */}
|
||||
{colorProps.length > 0 && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{colorProps.map(([key, val]) => (
|
||||
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropValue(key, v)} />
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Array props */}
|
||||
{arrayProps.map(([key, items]) => {
|
||||
const arrayItems = items as any[];
|
||||
const sampleItem = arrayItems[0] || {};
|
||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||
|
||||
return (
|
||||
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey={key}
|
||||
items={arrayItems}
|
||||
renderItem={(item: any, index: number) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(item)}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = e.target.value;
|
||||
props[key] = updated;
|
||||
});
|
||||
}}
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{itemFields.map((field) => {
|
||||
const fieldVal = item[field];
|
||||
if (typeof fieldVal === 'boolean') {
|
||||
return (
|
||||
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.checked };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} />
|
||||
{field}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (typeof fieldVal === 'number') {
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
<input type="number" value={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (/color/i.test(field) && typeof fieldVal === 'string') {
|
||||
return (
|
||||
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
|
||||
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const strVal = String(fieldVal ?? '');
|
||||
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
{isLongField ? (
|
||||
<textarea value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
|
||||
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Style controls */}
|
||||
<CollapsibleSection title="Style">
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setStyleValue('backgroundColor', v)} />
|
||||
</div>
|
||||
{/* Text color in style */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Color</SectionLabel>
|
||||
<ColorSwatchGrid colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v: string) => setStyleValue('color', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setStyleValue('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setStyleValue('textAlign', a)} title={a}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setStyleValue('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
269
craft/src/panels/right/styles/HeroStylePanel.tsx
Normal file
269
craft/src/panels/right/styles/HeroStylePanel.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
StylePanelProps,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
uploadToWhp,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- Asset Browser Inline ---------- */
|
||||
const AssetBrowser: React.FC<{
|
||||
filter: 'image' | 'video' | 'all';
|
||||
onSelect: (url: string) => void;
|
||||
}> = ({ filter, onSelect }) => {
|
||||
const [assets, setAssets] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const filtered = filter === 'all' ? data.assets : data.assets.filter((a: any) => {
|
||||
const t = (a.type || '').toLowerCase();
|
||||
return filter === 'image' ? t.startsWith('image') : t.startsWith('video');
|
||||
});
|
||||
setAssets(filtered);
|
||||
}
|
||||
} catch (e) { console.error('Load assets failed:', e); }
|
||||
setLoading(false);
|
||||
}, [filter]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!open) loadAssets();
|
||||
setOpen(!open);
|
||||
}, [open, loadAssets]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleToggle} style={{
|
||||
...btnActiveStyle(open), width: '100%', marginTop: 4,
|
||||
}}>
|
||||
<i className={`fa ${loading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} />
|
||||
{open ? 'Close' : 'Browse Assets'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ maxHeight: 160, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{assets.map((asset, i) => (
|
||||
<div key={i} onClick={() => { onSelect(asset.url); setOpen(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s', background: '#27272a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}>
|
||||
{(asset.type || '').startsWith('image') ? (
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 4 }}>
|
||||
<i className="fa fa-film" style={{ fontSize: 20, color: '#71717a' }} />
|
||||
<div style={{ fontSize: 8, color: '#71717a', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 70 }}>
|
||||
{asset.name?.replace(/^\d+_[a-f0-9]+_/, '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<p style={{ gridColumn: '1/-1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: 12, margin: 0 }}>
|
||||
No {filter} assets uploaded yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- HERO STYLE PANEL ---------- */
|
||||
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleUpload = useCallback(async (file: File, propKey: string) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp(propKey, url);
|
||||
}, [setProp]);
|
||||
|
||||
const bgType = nodeProps.bgType || 'color';
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollapsibleSection title="Content">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input type="text" value={nodeProps.heading || ''} onChange={(e) => setProp('heading', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Subtitle</label>
|
||||
<textarea value={nodeProps.subtitle || ''} onChange={(e) => setProp('subtitle', e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button URL</label>
|
||||
<input type="text" value={nodeProps.buttonHref || ''} onChange={(e) => setProp('buttonHref', e.target.value)} placeholder="#" style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Secondary Button</label>
|
||||
<input type="text" value={nodeProps.secondaryButtonText || ''} onChange={(e) => setProp('secondaryButtonText', e.target.value)} placeholder="Leave blank to hide" style={inputStyle} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Background">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
|
||||
<button key={t} onClick={() => setProp('bgType', t)} style={btnActiveStyle(bgType === t)}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bgType === 'color' && (
|
||||
<ColorPickerField label="Color" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
|
||||
)}
|
||||
|
||||
{bgType === 'gradient' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="From" value={nodeProps.bgGradientFrom || '#667eea'} onChange={(v) => setProp('bgGradientFrom', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="To" value={nodeProps.bgGradientTo || '#764ba2'} onChange={(v) => setProp('bgGradientTo', v)} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Angle: {nodeProps.bgGradientAngle || 135}°</label>
|
||||
<input type="range" min={0} max={360} value={nodeProps.bgGradientAngle || 135} onChange={(e) => setProp('bgGradientAngle', parseInt(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
{/* Gradient preview */}
|
||||
<div style={{ height: 24, borderRadius: 4, background: `linear-gradient(${nodeProps.bgGradientAngle || 135}deg, ${nodeProps.bgGradientFrom || '#667eea'}, ${nodeProps.bgGradientTo || '#764ba2'})`, border: '1px solid #3f3f46' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{bgType === 'image' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Image</label>
|
||||
{nodeProps.bgImage && (
|
||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
</div>
|
||||
<AssetBrowser filter="image" onSelect={(url) => setProp('bgImage', url)} />
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f, 'bgImage'); e.target.value = ''; }} />
|
||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste URL..."
|
||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bgType === 'video' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Video</label>
|
||||
{nodeProps.bgVideo && (
|
||||
<div style={{ marginBottom: 6, padding: 8, background: '#18181b', borderRadius: 4, border: '1px solid #3f3f46', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<i className="fa fa-film" style={{ color: '#3b82f6' }} />
|
||||
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{nodeProps.bgVideo.replace(/.*filename=/, '').replace(/^\d+_[a-f0-9]+_/, '') || nodeProps.bgVideo.split('/').pop()}
|
||||
</span>
|
||||
<button onClick={() => setProp('bgVideo', '')} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'video/*';
|
||||
input.onchange = () => { const f = input.files?.[0]; if (f) handleUpload(f, 'bgVideo'); };
|
||||
input.click();
|
||||
}} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload Video
|
||||
</button>
|
||||
</div>
|
||||
<AssetBrowser filter="video" onSelect={(url) => setProp('bgVideo', url)} />
|
||||
<input type="text" value={nodeProps.bgVideo || ''} placeholder="YouTube, Vimeo, or .mp4 URL"
|
||||
onChange={(e) => setProp('bgVideo', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Overlay ({nodeProps.overlayOpacity || 0}%)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={nodeProps.overlayColor || '#000000'} onChange={(e) => setProp('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Colors">
|
||||
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#ffffff'} onChange={(v) => setProp('textColor', v)} />
|
||||
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="Button BG" value={nodeProps.buttonBgColor || '#3b82f6'} onChange={(v) => setProp('buttonBgColor', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="Button Text" value={nodeProps.buttonTextColor || '#ffffff'} onChange={(v) => setProp('buttonTextColor', v)} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Layout">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Minimum Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
|
||||
<button key={h} onClick={() => setProp('minHeight', h)} style={btnActiveStyle(nodeProps.minHeight === h)}>
|
||||
{h === '100vh' ? 'Full' : h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Vertical</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['top', 'center', 'bottom'] as const).map((v) => (
|
||||
<button key={v} onClick={() => setProp('verticalAlign', v)} style={btnActiveStyle(nodeProps.verticalAlign === v)}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Text Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp('textAlign', a)} style={btnActiveStyle(nodeProps.textAlign === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
215
craft/src/panels/right/styles/ImageStylePanel.tsx
Normal file
215
craft/src/panels/right/styles/ImageStylePanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
uploadToWhp,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
||||
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
const [imgUrl, setImgUrl] = useState(nodeProps.src || '');
|
||||
|
||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg";
|
||||
const isPlaceholder = !nodeProps.src || nodeProps.src.startsWith('data:image/svg');
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = url; });
|
||||
setImgUrl(url);
|
||||
}
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
const maxWidthPresets = [
|
||||
{ label: '25%', value: '25%' },
|
||||
{ label: '50%', value: '50%' },
|
||||
{ label: '75%', value: '75%' },
|
||||
{ label: '100%', value: '100%' },
|
||||
];
|
||||
|
||||
const getFriendlyName = (src: string) => {
|
||||
const match = src.match(/filename=([^&]+)/);
|
||||
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
|
||||
return src.split('/').pop() || 'image';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Image source with upload/browse */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Image Source</SectionLabel>
|
||||
|
||||
{!isPlaceholder && nodeProps.src ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
||||
<button
|
||||
onClick={() => { actions.setProp(selectedId, (props: any) => { props.src = ''; }); setImgUrl(''); }}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(nodeProps.src || '')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleUpload(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||
Drop image here or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload + Browse buttons */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline asset browser grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = asset.url; });
|
||||
setImgUrl(asset.url);
|
||||
setShowBrowser(false);
|
||||
}}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input for advanced users */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div className="guided-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={imgUrl}
|
||||
placeholder="Or paste image URL..."
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<button
|
||||
className="preset-btn apply-btn"
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = imgUrl; });
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt Text */}
|
||||
<TextInputField
|
||||
label="Alt Text"
|
||||
value={nodeProps.alt || ''}
|
||||
placeholder="Describe the image..."
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.alt = v; });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Border Radius */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Width */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Max Width</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={maxWidthPresets}
|
||||
activeValue={style.maxWidth as string}
|
||||
onSelect={(v) => setPropStyle('maxWidth', v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
197
craft/src/panels/right/styles/MediaStylePanel.tsx
Normal file
197
craft/src/panels/right/styles/MediaStylePanel.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
|
||||
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Video URL */}
|
||||
{nodeProps.videoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Video URL</label>
|
||||
<input type="text" value={nodeProps.videoUrl || ''} onChange={(e) => setProp('videoUrl', e.target.value)} placeholder="YouTube, Vimeo, or .mp4 URL" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map URL */}
|
||||
{nodeProps.embedUrl !== undefined && nodeProps.videoUrl === undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Embed URL</label>
|
||||
<input type="text" value={nodeProps.embedUrl || ''} onChange={(e) => setProp('embedUrl', e.target.value)} placeholder="Google Maps embed URL" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map address */}
|
||||
{nodeProps.address !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Address</label>
|
||||
<input type="text" value={nodeProps.address || ''} onChange={(e) => setProp('address', e.target.value)} placeholder="123 Main St..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video options */}
|
||||
{nodeProps.autoplay !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.autoplay || false} onChange={(e) => setProp('autoplay', e.target.checked)} />
|
||||
Autoplay
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.loop !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.loop || false} onChange={(e) => setProp('loop', e.target.checked)} />
|
||||
Loop
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.controls !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.controls !== false} onChange={(e) => setProp('controls', e.target.checked)} />
|
||||
Show Controls
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery items */}
|
||||
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
|
||||
<CollapsibleSection title="Images">
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey="images"
|
||||
items={nodeProps.images}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{item.src !== undefined && (
|
||||
<input type="text" value={item.src || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.images || [])];
|
||||
updated[index] = { ...updated[index], src: e.target.value };
|
||||
props.images = updated;
|
||||
});
|
||||
}} placeholder="Image URL" style={smallInputStyle} />
|
||||
)}
|
||||
{item.caption !== undefined && (
|
||||
<input type="text" value={item.caption || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.images || [])];
|
||||
updated[index] = { ...updated[index], caption: e.target.value };
|
||||
props.images = updated;
|
||||
});
|
||||
}} placeholder="Caption" style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ src: '', caption: '' }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Slides */}
|
||||
{nodeProps.slides !== undefined && Array.isArray(nodeProps.slides) && (
|
||||
<CollapsibleSection title="Slides">
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey="slides"
|
||||
items={nodeProps.slides}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{item.heading !== undefined && (
|
||||
<input type="text" value={item.heading || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], heading: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Heading" style={smallInputStyle} />
|
||||
)}
|
||||
{item.text !== undefined && (
|
||||
<textarea value={item.text || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], text: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
)}
|
||||
{item.image !== undefined && (
|
||||
<input type="text" value={item.image || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], image: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Image URL" style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ heading: 'New Slide', text: '', image: '' }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{nodeProps.overlayColor !== undefined && (
|
||||
<CollapsibleSection title="Overlay" defaultOpen={false}>
|
||||
<ColorPickerField label="Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
|
||||
{nodeProps.overlayOpacity !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {nodeProps.overlayOpacity ?? 0}%</label>
|
||||
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity ?? 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
192
craft/src/panels/right/styles/NavStylePanel.tsx
Normal file
192
craft/src/panels/right/styles/NavStylePanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- NAV / MENU / LOGO ---------- */
|
||||
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const links: any[] = nodeProps.links || [];
|
||||
|
||||
const updateLink = useCallback((index: number, field: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.links = [...(props.links || []), { text: 'New Link', href: '#' }];
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removeLink = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated.splice(index, 1);
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
/* Detect standalone Logo vs Navbar/Menu */
|
||||
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Standalone Logo component settings */}
|
||||
{isStandaloneLogo && (
|
||||
<CollapsibleSection title="Logo" defaultOpen={true}>
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp('type', 'text')}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'text' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'text' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'text' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-font" style={{ marginRight: 4 }} />Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp('type', 'image')}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'image' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'image' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'image' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-image" style={{ marginRight: 4 }} />Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{nodeProps.type === 'text' && (
|
||||
<>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Font Size</label>
|
||||
<input type="text" value={nodeProps.fontSize || '20px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Font Weight</label>
|
||||
<select value={nodeProps.fontWeight || '700'} onChange={(e) => setProp('fontWeight', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
<option value="300">Light</option>
|
||||
<option value="400">Normal</option>
|
||||
<option value="500">Medium</option>
|
||||
<option value="600">Semi</option>
|
||||
<option value="700">Bold</option>
|
||||
<option value="800">Extra Bold</option>
|
||||
</select>
|
||||
</div>
|
||||
<ColorPickerField label="Text Color" value={nodeProps.color || '#1f2937'} onChange={(v) => setProp('color', v)} />
|
||||
</>
|
||||
)}
|
||||
{nodeProps.type === 'image' && (
|
||||
<>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Image URL</label>
|
||||
<input type="text" value={nodeProps.imageSrc || ''} onChange={(e) => setProp('imageSrc', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Image Width</label>
|
||||
<input type="text" value={nodeProps.imageWidth || '120px'} onChange={(e) => setProp('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input type="text" value={nodeProps.href || '/'} onChange={(e) => setProp('href', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Navbar Logo settings */}
|
||||
{nodeProps.logoText !== undefined && (
|
||||
<CollapsibleSection title="Logo">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input type="text" value={nodeProps.logoText || ''} onChange={(e) => setProp('logoText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{nodeProps.logoImage !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Image URL</label>
|
||||
<input type="text" value={nodeProps.logoImage || ''} onChange={(e) => setProp('logoImage', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.logoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Link URL</label>
|
||||
<input type="text" value={nodeProps.logoUrl || ''} onChange={(e) => setProp('logoUrl', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Links (not shown for standalone Logo) */}
|
||||
{!isStandaloneLogo && (
|
||||
<CollapsibleSection title="Links">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={link.text || ''} onChange={(e) => updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
|
||||
<button onClick={() => removeLink(i)} style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={link.href || ''} onChange={(e) => updateLink(i, 'href', e.target.value)} placeholder="URL" style={{ ...smallInputStyle, color: '#71717a' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addLink} style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}>
|
||||
+ Add Link
|
||||
</button>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Colors (not shown for standalone Logo - it has its own color picker) */}
|
||||
{!isStandaloneLogo && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{nodeProps.backgroundColor !== undefined && (
|
||||
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} />
|
||||
)}
|
||||
{nodeProps.textColor !== undefined && (
|
||||
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
|
||||
)}
|
||||
{nodeProps.ctaColor !== undefined && (
|
||||
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Style overrides */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={(nodeProps.style || {}).padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
210
craft/src/panels/right/styles/PricingStylePanel.tsx
Normal file
210
craft/src/panels/right/styles/PricingStylePanel.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
StylePanelProps,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
const bulletOptions = [
|
||||
{ label: '✓', value: 'check' },
|
||||
{ label: '●', value: 'dot' },
|
||||
{ label: '→', value: 'arrow' },
|
||||
{ label: '★', value: 'star' },
|
||||
{ label: '—', value: 'dash' },
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
const bulletChar: Record<string, string> = {
|
||||
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
|
||||
};
|
||||
|
||||
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const [expandedPlan, setExpandedPlan] = useState<number>(0);
|
||||
|
||||
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
|
||||
const currentBullet = nodeProps.bulletType || 'check';
|
||||
|
||||
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated[planIndex] = { ...updated[planIndex], [field]: value };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addPlan = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated.push({
|
||||
name: 'New Plan',
|
||||
price: '$0',
|
||||
period: '/month',
|
||||
features: ['Feature 1'],
|
||||
buttonText: 'Choose Plan',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
});
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removePlan = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated.splice(index, 1);
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addFeature = useCallback((planIndex: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features.push('New feature');
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const updateFeature = useCallback((planIndex: number, featureIndex: number, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features[featureIndex] = value;
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removeFeature = useCallback((planIndex: number, featureIndex: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features.splice(featureIndex, 1);
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bullet type */}
|
||||
<CollapsibleSection title="Bullet Style">
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bulletOptions.map((b) => (
|
||||
<button key={b.value} onClick={() => actions.setProp(selectedId, (p: any) => { p.bulletType = b.value; })}
|
||||
style={{ ...btnActiveStyle(currentBullet === b.value), flex: 1, fontSize: 14 }}>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Plans */}
|
||||
<CollapsibleSection title={`Plans (${plans.length})`}>
|
||||
{plans.map((plan, i) => {
|
||||
const isExpanded = expandedPlan === i;
|
||||
const features: string[] = Array.isArray(plan.features) ? plan.features : [];
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
marginBottom: 8, background: '#18181b', borderRadius: 6,
|
||||
border: plan.isFeatured ? '1px solid #3b82f6' : '1px solid #27272a',
|
||||
}}>
|
||||
{/* Plan header - click to expand */}
|
||||
<div onClick={() => setExpandedPlan(isExpanded ? -1 : i)} style={{
|
||||
padding: '8px 10px', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7' }}>
|
||||
{plan.name || 'Plan'} {plan.isFeatured && <span style={{ fontSize: 9, background: '#3b82f6', color: '#fff', padding: '1px 5px', borderRadius: 3, marginLeft: 4 }}>Featured</span>}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#71717a' }}>{plan.price}</span>
|
||||
<i className={`fa fa-chevron-${isExpanded ? 'up' : 'down'}`} style={{ fontSize: 10, color: '#71717a' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded plan settings */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '0 10px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Name</label>
|
||||
<input type="text" value={plan.name || ''} onChange={(e) => updatePlan(i, 'name', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Price</label>
|
||||
<input type="text" value={plan.price || ''} onChange={(e) => updatePlan(i, 'price', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Period</label>
|
||||
<input type="text" value={plan.period || ''} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Button Text</label>
|
||||
<input type="text" value={plan.buttonText || ''} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Button URL</label>
|
||||
<input type="text" value={plan.buttonHref || ''} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!plan.isFeatured} onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)} />
|
||||
Featured (highlighted)
|
||||
</label>
|
||||
|
||||
{/* Features list */}
|
||||
<div>
|
||||
<label style={{ fontSize: 9, color: '#52525b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Features ({features.length})</span>
|
||||
<button onClick={() => addFeature(i)} style={{ fontSize: 9, background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 3, padding: '2px 6px', cursor: 'pointer' }}>
|
||||
+ Add
|
||||
</button>
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4 }}>
|
||||
{features.map((feat, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#10b981', width: 14, textAlign: 'center' }}>{bulletChar[currentBullet] || '✓'}</span>
|
||||
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...smallInputStyle, flex: 1 }} />
|
||||
<button onClick={() => removeFeature(i, fi)} style={{ fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, padding: '1px 4px', cursor: 'pointer', lineHeight: 1 }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove plan */}
|
||||
{plans.length > 1 && (
|
||||
<button onClick={() => removePlan(i)} style={{ fontSize: 10, background: 'none', color: '#ef4444', border: '1px solid #ef4444', borderRadius: 4, padding: '3px 8px', cursor: 'pointer', marginTop: 4 }}>
|
||||
Remove Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button onClick={addPlan} style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', marginTop: 4 }}>
|
||||
+ Add Plan
|
||||
</button>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Colors */}
|
||||
<CollapsibleSection title="Colors" defaultOpen={false}>
|
||||
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
239
craft/src/panels/right/styles/SectionTypePanel.tsx
Normal file
239
craft/src/panels/right/styles/SectionTypePanel.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
||||
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
// Find all string/number/boolean props
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
const scalarProps = Object.entries(nodeProps).filter(
|
||||
([key, val]) => !SKIP_PROPS.has(key) && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean')
|
||||
);
|
||||
const colorProps = scalarProps.filter(([key]) => /color/i.test(key));
|
||||
const otherScalarProps = scalarProps.filter(([key]) => !/color/i.test(key));
|
||||
const arrayProps = Object.entries(nodeProps).filter(([key, val]) => !SKIP_PROPS.has(key) && Array.isArray(val));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Content props */}
|
||||
{otherScalarProps.length > 0 && (
|
||||
<CollapsibleSection title="Content">
|
||||
{otherScalarProps.map(([key, val]) => {
|
||||
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
|
||||
if (typeof val === 'boolean') {
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={val} onChange={(e) => setProp(key, e.target.checked)} />
|
||||
{humanLabel}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof val === 'number') {
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
<input type="number" value={val} onChange={(e) => setProp(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// String - use textarea for long values
|
||||
const isLong = String(val).length > 60;
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
{isLong ? (
|
||||
<textarea value={String(val)} onChange={(e) => setProp(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={String(val)} onChange={(e) => setProp(key, e.target.value)} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Color props */}
|
||||
{colorProps.length > 0 && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{colorProps.map(([key, val]) => (
|
||||
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setProp(key, v)} />
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Array props (features, items, plans, testimonials, etc.) */}
|
||||
{arrayProps.map(([key, items]) => {
|
||||
const arrayItems = items as any[];
|
||||
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
|
||||
const sampleItem = arrayItems[0] || {};
|
||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||
|
||||
return (
|
||||
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey={key}
|
||||
items={arrayItems}
|
||||
renderItem={(item: any, index: number) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(item)}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = e.target.value;
|
||||
props[key] = updated;
|
||||
});
|
||||
}}
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{itemFields.map((field) => {
|
||||
const fieldVal = item[field];
|
||||
if (typeof fieldVal === 'boolean') {
|
||||
return (
|
||||
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.checked };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} />
|
||||
{field}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (typeof fieldVal === 'number') {
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
<input type="number" value={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// color fields
|
||||
if (/color/i.test(field) && typeof fieldVal === 'string') {
|
||||
return (
|
||||
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
|
||||
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// long text
|
||||
const strVal = String(fieldVal ?? '');
|
||||
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
{isLongField ? (
|
||||
<textarea value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
|
||||
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Style */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setPropStyle('textAlign', a)} title={a}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
177
craft/src/panels/right/styles/SocialStylePanel.tsx
Normal file
177
craft/src/panels/right/styles/SocialStylePanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
|
||||
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Social Links list */}
|
||||
{nodeProps.links !== undefined && Array.isArray(nodeProps.links) && (
|
||||
<CollapsibleSection title="Links">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(nodeProps.links || []).map((link: any, i: number) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa', width: 60, textTransform: 'capitalize' }}>{link.platform || 'link'}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={link.url || ''}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated[i] = { ...updated[i], url: e.target.value };
|
||||
props.links = updated;
|
||||
});
|
||||
}}
|
||||
placeholder="URL"
|
||||
style={{ ...smallInputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated.splice(i, 1);
|
||||
props.links = updated;
|
||||
});
|
||||
}}
|
||||
style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) return;
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.links = [...(props.links || []), { platform: e.target.value, url: '#' }];
|
||||
});
|
||||
e.target.value = '';
|
||||
}}
|
||||
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">+ Add Platform...</option>
|
||||
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp'].map((p) => (
|
||||
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Icon name */}
|
||||
{nodeProps.iconName !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon Name</label>
|
||||
<input type="text" value={nodeProps.iconName || ''} onChange={(e) => setProp('iconName', e.target.value)} placeholder="fa-star" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.icon !== undefined && typeof nodeProps.icon === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon</label>
|
||||
<input type="text" value={nodeProps.icon || ''} onChange={(e) => setProp('icon', e.target.value)} placeholder="fa-star or emoji" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star rating */}
|
||||
{nodeProps.rating !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Rating: {nodeProps.rating || 0}</label>
|
||||
<input type="range" min={0} max={5} step={0.5} value={nodeProps.rating || 0} onChange={(e) => setProp('rating', parseFloat(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.maxStars !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Max Stars</label>
|
||||
<input type="number" min={1} max={10} value={nodeProps.maxStars || 5} onChange={(e) => setProp('maxStars', parseInt(e.target.value) || 5)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
{nodeProps.iconColor !== undefined && (
|
||||
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
|
||||
)}
|
||||
{nodeProps.iconBgColor !== undefined && (
|
||||
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setProp('iconBgColor', v)} />
|
||||
)}
|
||||
{nodeProps.starColor !== undefined && (
|
||||
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setProp('starColor', v)} />
|
||||
)}
|
||||
{nodeProps.color !== undefined && typeof nodeProps.color === 'string' && (
|
||||
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setProp('color', v)} />
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
{nodeProps.iconSize !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon Size</label>
|
||||
<input type="text" value={nodeProps.iconSize || '24px'} onChange={(e) => setProp('iconSize', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.size !== undefined && typeof nodeProps.size === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<input type="text" value={nodeProps.size || '24px'} onChange={(e) => setProp('size', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alignment */}
|
||||
{nodeProps.alignment !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle(nodeProps.alignment === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
81
craft/src/panels/right/styles/TextStylePanel.tsx
Normal file
81
craft/src/panels/right/styles/TextStylePanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
FONT_FAMILIES,
|
||||
TEXT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- TEXT ---------- */
|
||||
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={TEXT_COLORS}
|
||||
activeValue={style.color as string}
|
||||
onSelect={(v) => setPropStyle('color', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Font Family</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={FONT_FAMILIES}
|
||||
activeValue={style.fontFamily as string}
|
||||
onSelect={(v) => setPropStyle('fontFamily', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Size</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={TEXT_SIZES}
|
||||
activeValue={style.fontSize as string}
|
||||
onSelect={(v) => setPropStyle('fontSize', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Font Weight</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={FONT_WEIGHTS}
|
||||
activeValue={String(style.fontWeight || '')}
|
||||
onSelect={(v) => setPropStyle('fontWeight', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
|
||||
onClick={() => setPropStyle('textAlign', a)}
|
||||
title={a}
|
||||
>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
13
craft/src/panels/right/styles/index.ts
Normal file
13
craft/src/panels/right/styles/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { TextStylePanel } from './TextStylePanel';
|
||||
export { ButtonStylePanel } from './ButtonStylePanel';
|
||||
export { ImageStylePanel } from './ImageStylePanel';
|
||||
export { ContainerStylePanel } from './ContainerStylePanel';
|
||||
export { HeroStylePanel } from './HeroStylePanel';
|
||||
export { NavStylePanel } from './NavStylePanel';
|
||||
export { MediaStylePanel } from './MediaStylePanel';
|
||||
export { FormStylePanel } from './FormStylePanel';
|
||||
export { SocialStylePanel } from './SocialStylePanel';
|
||||
export { SectionTypePanel } from './SectionTypePanel';
|
||||
export { PricingStylePanel } from './PricingStylePanel';
|
||||
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
|
||||
export { GenericPropsEditor } from './GenericPropsEditor';
|
||||
322
craft/src/panels/right/styles/shared.tsx
Normal file
322
craft/src/panels/right/styles/shared.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
GRADIENTS,
|
||||
} from '../../../constants/presets';
|
||||
|
||||
/* ---------- Helper: auto text color for bg ---------- */
|
||||
export function autoTextColor(bg: string): string {
|
||||
if (bg.startsWith('#')) {
|
||||
const hex = bg.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#18181b' : '#ffffff';
|
||||
}
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
/* ---------- Helper: upload to WHP ---------- */
|
||||
export async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/* ---------- Shared inline styles ---------- */
|
||||
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
|
||||
export const inputStyle: CSSProperties = { width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' };
|
||||
export const smallInputStyle: CSSProperties = { ...inputStyle, fontSize: 11, padding: '3px 6px' };
|
||||
export const btnActiveStyle = (active: boolean): CSSProperties => ({
|
||||
flex: 1, padding: '5px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
export const sectionGap: CSSProperties = { marginBottom: 14 };
|
||||
|
||||
/* ---------- Reusable sub-components ---------- */
|
||||
|
||||
interface SectionLabelProps { children: React.ReactNode; }
|
||||
export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
|
||||
<label className="guided-section-label">{children}</label>
|
||||
);
|
||||
|
||||
interface ColorSwatchGridProps {
|
||||
colors: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect }) => (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={activeValue || '#000000'}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={activeValue || ''}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
placeholder="#000000"
|
||||
style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }}
|
||||
/>
|
||||
</div>
|
||||
<div className="preset-grid">
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
className={`preset-swatch ${activeValue === c.value ? 'active' : ''}`}
|
||||
style={{ background: c.value }}
|
||||
onClick={() => onSelect(c.value)}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PresetButtonGridProps {
|
||||
presets: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
|
||||
<div className="preset-grid">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
|
||||
onClick={() => onSelect(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface GradientSwatchGridProps {
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
|
||||
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
|
||||
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
|
||||
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8})/);
|
||||
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
|
||||
return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
}
|
||||
|
||||
export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeValue, onSelect }) => {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const parsed = parseGradient(activeValue);
|
||||
const [customFrom, setCustomFrom] = useState(parsed.from);
|
||||
const [customTo, setCustomTo] = useState(parsed.to);
|
||||
const [customAngle, setCustomAngle] = useState(parsed.angle);
|
||||
|
||||
const applyCustomGradient = (from: string, to: string, angle: number) => {
|
||||
setCustomFrom(from);
|
||||
setCustomTo(to);
|
||||
setCustomAngle(angle);
|
||||
onSelect(`linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Custom gradient builder toggle */}
|
||||
<button
|
||||
onClick={() => setShowCustom(!showCustom)}
|
||||
style={{
|
||||
width: '100%', padding: '5px 8px', fontSize: 11, marginBottom: 6,
|
||||
background: showCustom ? '#3b82f6' : '#27272a', color: showCustom ? '#fff' : '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<i className={`fa fa-${showCustom ? 'chevron-down' : 'sliders'}`} style={{ fontSize: 10 }} />
|
||||
Custom Gradient
|
||||
</button>
|
||||
{showCustom && (
|
||||
<div style={{ padding: 8, background: '#1e1e22', borderRadius: 6, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>From</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||||
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
<input type="text" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||||
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>To</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||||
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
<input type="text" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||||
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>Angle: {customAngle}°</label>
|
||||
<input type="range" min={0} max={360} value={customAngle} onChange={(e) => applyCustomGradient(customFrom, customTo, parseInt(e.target.value))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
{/* Live preview */}
|
||||
<div style={{ height: 20, borderRadius: 4, marginTop: 6, background: `linear-gradient(${customAngle}deg, ${customFrom}, ${customTo})`, border: '1px solid #3f3f46' }} />
|
||||
</div>
|
||||
)}
|
||||
{/* Preset swatches */}
|
||||
<div className="preset-grid gradient-grid">
|
||||
{GRADIENTS.map((g) => (
|
||||
<button
|
||||
key={g.label}
|
||||
className={`preset-swatch gradient-swatch ${activeValue === g.value ? 'active' : ''}`}
|
||||
style={{ background: g.value === 'none' ? '#27272a' : g.value }}
|
||||
onClick={() => onSelect(g.value)}
|
||||
title={g.label}
|
||||
>
|
||||
{g.value === 'none' ? '\u00D7' : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextInputFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const TextInputField: React.FC<TextInputFieldProps> = ({ label, value, placeholder, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Color picker with hex input ---------- */
|
||||
interface ColorPickerFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange }) => (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value || '#000000'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Collapsible section ---------- */
|
||||
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #2d2d3a', paddingTop: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', background: 'none', border: 'none', color: '#a1a1aa', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
<i className={`fa fa-chevron-${open ? 'down' : 'right'}`} style={{ fontSize: 8, width: 10 }} />
|
||||
{title}
|
||||
</button>
|
||||
{open && <div style={{ paddingTop: 8 }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- StylePanelProps interface ---------- */
|
||||
export interface StylePanelProps {
|
||||
selectedId: string;
|
||||
nodeProps: Record<string, any>;
|
||||
}
|
||||
|
||||
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
|
||||
interface ArrayPropEditorProps {
|
||||
selectedId: string;
|
||||
propKey: string;
|
||||
items: any[];
|
||||
renderItem: (item: any, index: number) => React.ReactNode;
|
||||
emptyItem: any;
|
||||
}
|
||||
export const ArrayPropEditor: React.FC<ArrayPropEditorProps> = ({ selectedId, propKey, items, renderItem, emptyItem }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props[propKey] = [...(props[propKey] || []), typeof emptyItem === 'object' ? { ...emptyItem } : emptyItem];
|
||||
});
|
||||
}, [actions, selectedId, propKey, emptyItem]);
|
||||
|
||||
const removeItem = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[propKey] || [])];
|
||||
updated.splice(index, 1);
|
||||
props[propKey] = updated;
|
||||
});
|
||||
}, [actions, selectedId, propKey]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
style={{ position: 'absolute', top: 4, right: 4, padding: '1px 5px', fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', zIndex: 1 }}
|
||||
title="Remove"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
{renderItem(item, i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
159
craft/src/panels/topbar/HeadCodeModal.tsx
Normal file
159
craft/src/panels/topbar/HeadCodeModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
|
||||
interface HeadCodeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div style={backdropStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={modalHeaderStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<i className="fa fa-code" style={{ color: 'var(--color-accent)', fontSize: 16 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Custom Head Code
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
Add tracking scripts, meta tags, or custom CSS to your site's <head> section.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeButtonStyle}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 20, flex: 1, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(59,130,246,0.08)',
|
||||
border: '1px solid rgba(59,130,246,0.2)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
<i className="fa fa-info-circle" style={{ color: 'var(--color-accent)', marginRight: 6 }} />
|
||||
Code added here will be injected into the <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 4px', borderRadius: 3, fontSize: 11 }}><head></code> of every page on your site. Use it for analytics, custom fonts, or global CSS.
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={design.headCode || ''}
|
||||
onChange={(e) => updateDesign({ headCode: e.target.value })}
|
||||
placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 300,
|
||||
padding: 14,
|
||||
background: '#0d0d0f',
|
||||
color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 8,
|
||||
fontFamily: 'Source Code Pro, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
tabSize: 2,
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
}}>
|
||||
<button onClick={onClose} style={doneButtonStyle}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Styles ---------- */
|
||||
|
||||
const backdropStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: '90vw',
|
||||
maxWidth: 700,
|
||||
maxHeight: '80vh',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
};
|
||||
|
||||
const modalHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const doneButtonStyle: React.CSSProperties = {
|
||||
padding: '8px 24px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: 'var(--color-accent)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
607
craft/src/panels/topbar/TemplateModal.tsx
Normal file
607
craft/src/panels/topbar/TemplateModal.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import {
|
||||
allTemplates,
|
||||
TemplateDefinition,
|
||||
TemplateComponent,
|
||||
TemplateCategory,
|
||||
} from '../../templates';
|
||||
import { componentResolver } from '../../components/resolver';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TemplateModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type FilterTab = 'all' | TemplateCategory;
|
||||
|
||||
const TABS: { label: string; value: FilterTab }[] = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Business', value: 'business' },
|
||||
{ label: 'Creative', value: 'creative' },
|
||||
{ label: 'Personal', value: 'personal' },
|
||||
{ label: 'Community', value: 'community' },
|
||||
];
|
||||
|
||||
const CATEGORY_COLORS: Record<TemplateCategory, string> = {
|
||||
business: '#3b82f6',
|
||||
creative: '#a855f7',
|
||||
personal: '#f59e0b',
|
||||
community: '#10b981',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) => {
|
||||
const { actions, query } = useEditor();
|
||||
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
|
||||
const { updateDesign } = useSiteDesign();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>('all');
|
||||
const [confirmTemplate, setConfirmTemplate] = useState<TemplateDefinition | null>(null);
|
||||
const [applyDesign, setApplyDesign] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (confirmTemplate) setConfirmTemplate(null);
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, confirmTemplate, onClose]);
|
||||
|
||||
// Filter templates by category
|
||||
const filtered = useMemo(() => {
|
||||
if (activeTab === 'all') return allTemplates;
|
||||
return allTemplates.filter((t) => t.category === activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
// Resolve a TemplateComponent type name to its React component
|
||||
const resolverMap = componentResolver as Record<string, React.ComponentType<any>>;
|
||||
|
||||
/**
|
||||
* Add all components from a template definition onto the current (empty) canvas ROOT.
|
||||
* Uses Craft.js parseReactElement + addNodeTree which correctly builds valid node structures.
|
||||
*/
|
||||
const addTemplateComponents = useCallback(
|
||||
(components: TemplateComponent[]) => {
|
||||
for (const comp of components) {
|
||||
const Component = resolverMap[comp.type];
|
||||
if (!Component) {
|
||||
console.warn(`Template references unknown component type: ${comp.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const element = React.createElement(Component, comp.props);
|
||||
const tree = query.parseReactElement(element).toNodeTree();
|
||||
actions.addNodeTree(tree, 'ROOT');
|
||||
}
|
||||
},
|
||||
[query, actions, resolverMap],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear the current canvas by deleting all children of ROOT.
|
||||
*/
|
||||
const clearCanvas = useCallback(() => {
|
||||
try {
|
||||
const rootNode = query.node('ROOT').get();
|
||||
const childIds = [...(rootNode.data.nodes || [])];
|
||||
childIds.forEach((id) => {
|
||||
try {
|
||||
actions.delete(id);
|
||||
} catch {
|
||||
// Node may already be removed
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ROOT doesn't exist yet or is empty -- that's fine
|
||||
}
|
||||
}, [query, actions]);
|
||||
|
||||
/**
|
||||
* After all pages are loaded, apply header and footer template content.
|
||||
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
|
||||
*/
|
||||
const applyHeaderFooter = useCallback(
|
||||
(tpl: TemplateDefinition, firstPageId: string) => {
|
||||
const hasHeader = tpl.header?.components?.length > 0;
|
||||
const hasFooter = tpl.footer?.components?.length > 0;
|
||||
|
||||
if (!hasHeader && !hasFooter) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const applyZone = (
|
||||
switchFn: () => void,
|
||||
components: TemplateComponent[],
|
||||
next: () => void,
|
||||
) => {
|
||||
switchFn();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
clearCanvas();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
addTemplateComponents(components);
|
||||
} catch (e) {
|
||||
console.warn('Failed to add zone components:', e);
|
||||
}
|
||||
setTimeout(next, 30);
|
||||
}, 20);
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear zone:', e);
|
||||
setTimeout(next, 30);
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
// Switch back to the first page
|
||||
switchPage(firstPageId);
|
||||
setTimeout(() => setLoading(false), 30);
|
||||
};
|
||||
|
||||
const doFooter = () => {
|
||||
if (hasFooter) {
|
||||
applyZone(editFooter, tpl.footer.components, finish);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
if (hasHeader) {
|
||||
applyZone(editHeader, tpl.header.components, doFooter);
|
||||
} else {
|
||||
doFooter();
|
||||
}
|
||||
},
|
||||
[clearCanvas, addTemplateComponents, switchPage, editHeader, editFooter],
|
||||
);
|
||||
|
||||
// Load the selected template
|
||||
const handleLoad = useCallback(() => {
|
||||
if (!confirmTemplate) return;
|
||||
setLoading(true);
|
||||
|
||||
const tpl = confirmTemplate;
|
||||
|
||||
try {
|
||||
// 1. Optionally apply design tokens
|
||||
if (applyDesign && tpl.design) {
|
||||
updateDesign(tpl.design);
|
||||
}
|
||||
|
||||
// 2. Remove all pages except the first
|
||||
const currentPages = [...pages];
|
||||
const keepId = currentPages[0]?.id;
|
||||
for (let i = currentPages.length - 1; i >= 1; i--) {
|
||||
deletePage(currentPages[i].id);
|
||||
}
|
||||
|
||||
// 3. Clear the current canvas and add the first template page's components
|
||||
clearCanvas();
|
||||
|
||||
// Use a short delay to let the clear settle before adding new nodes
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const firstPage = tpl.pages[0];
|
||||
addTemplateComponents(firstPage.content.components);
|
||||
|
||||
// 4. Add remaining pages (if multi-page template)
|
||||
const finishPages = () => {
|
||||
// 5. Apply header and footer template content
|
||||
if (keepId) {
|
||||
applyHeaderFooter(tpl, keepId);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (tpl.pages.length > 1) {
|
||||
let pageIndex = 1;
|
||||
const addNextPage = () => {
|
||||
if (pageIndex >= tpl.pages.length) {
|
||||
// Switch back to first page, then apply header/footer
|
||||
if (keepId) switchPage(keepId);
|
||||
setTimeout(finishPages, 30);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageDef = tpl.pages[pageIndex];
|
||||
addPage(pageDef.name, pageDef.slug);
|
||||
|
||||
// After addPage, the new page is active with an empty canvas.
|
||||
// Give a tick for Craft.js to settle, then add components.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
addTemplateComponents(pageDef.content.components);
|
||||
} catch (e) {
|
||||
console.warn('Failed to add components for page', pageDef.name, e);
|
||||
}
|
||||
pageIndex++;
|
||||
setTimeout(addNextPage, 30);
|
||||
}, 30);
|
||||
};
|
||||
|
||||
setTimeout(addNextPage, 30);
|
||||
} else {
|
||||
finishPages();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load template:', e);
|
||||
setLoading(false);
|
||||
}
|
||||
}, 20);
|
||||
} catch (e) {
|
||||
console.error('Failed to load template:', e);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setConfirmTemplate(null);
|
||||
onClose();
|
||||
}, [confirmTemplate, applyDesign, query, actions, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
if (confirmTemplate) {
|
||||
setConfirmTemplate(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[confirmTemplate, onClose],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div style={backdropStyle} onClick={handleBackdropClick}>
|
||||
<div style={modalStyle}>
|
||||
{/* Header */}
|
||||
<div style={modalHeaderStyle}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#e4e4e7' }}>
|
||||
Templates
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#71717a' }}>
|
||||
Choose a template to get started quickly
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeButtonStyle} title="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div style={tabBarStyle}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
style={{
|
||||
...tabStyle,
|
||||
...(activeTab === tab.value ? tabActiveStyle : {}),
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template Grid */}
|
||||
<div style={gridContainerStyle}>
|
||||
<div style={gridStyle}>
|
||||
{filtered.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
onSelect={() => setConfirmTemplate(tpl)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: 40, color: '#71717a' }}>
|
||||
No templates in this category.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmTemplate && (
|
||||
<div style={confirmOverlayStyle} onClick={() => setConfirmTemplate(null)}>
|
||||
<div style={confirmDialogStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: '#e4e4e7' }}>
|
||||
Load "{confirmTemplate.name}"?
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: '#a1a1aa' }}>
|
||||
This will replace your current content with the template pages and components.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 13,
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
marginBottom: 24,
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyDesign}
|
||||
onChange={(e) => setApplyDesign(e.target.checked)}
|
||||
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer' }}
|
||||
/>
|
||||
Apply template colors and fonts to site design
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setConfirmTemplate(null)}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#a1a1aa',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#ffffff',
|
||||
background: '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load Template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TemplateCard sub-component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TemplateCard: React.FC<{
|
||||
template: TemplateDefinition;
|
||||
onSelect: () => void;
|
||||
}> = ({ template, onSelect }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${hovered ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
backgroundColor: hovered ? 'var(--color-bg-elevated)' : 'var(--color-bg-surface)',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
transform: hovered ? 'translateY(-2px)' : 'none',
|
||||
boxShadow: hovered ? '0 4px 12px rgba(0,0,0,0.3)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 120,
|
||||
backgroundColor: '#1a1a24',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={template.thumbnail}
|
||||
alt={template.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#e4e4e7' }}>
|
||||
{template.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: CATEGORY_COLORS[template.category],
|
||||
backgroundColor: `${CATEGORY_COLORS[template.category]}18`,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 11, color: '#71717a', lineHeight: 1.4 }}>
|
||||
{template.description}
|
||||
</p>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#52525b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-file-o" style={{ fontSize: 9 }} />
|
||||
{template.isMultiPage ? `${template.pages.length} pages` : 'Single page'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const backdropStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: '90vw',
|
||||
maxWidth: 900,
|
||||
maxHeight: '85vh',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
};
|
||||
|
||||
const modalHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 14,
|
||||
color: '#71717a',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const tabBarStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
padding: '12px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
overflowX: 'auto',
|
||||
};
|
||||
|
||||
const tabStyle: React.CSSProperties = {
|
||||
padding: '6px 16px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: '#71717a',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.15s ease',
|
||||
};
|
||||
|
||||
const tabActiveStyle: React.CSSProperties = {
|
||||
color: '#e4e4e7',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderColor: 'var(--color-border)',
|
||||
};
|
||||
|
||||
const gridContainerStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 20px 20px',
|
||||
};
|
||||
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const confirmOverlayStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const confirmDialogStyle: React.CSSProperties = {
|
||||
width: '90%',
|
||||
maxWidth: 420,
|
||||
padding: '24px',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||
};
|
||||
274
craft/src/panels/topbar/TopBar.tsx
Normal file
274
craft/src/panels/topbar/TopBar.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { useWhpApi } from '../../hooks/useWhpApi';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { DeviceMode } from '../../types';
|
||||
import { TemplateModal } from './TemplateModal';
|
||||
import { HeadCodeModal } from './HeadCodeModal';
|
||||
|
||||
interface TopBarProps {
|
||||
device: DeviceMode;
|
||||
onDeviceChange: (device: DeviceMode) => void;
|
||||
}
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
const { actions, query, canUndo, canRedo } = useEditor((_state, query) => ({
|
||||
canUndo: query.history.canUndo(),
|
||||
canRedo: query.history.canRedo(),
|
||||
}));
|
||||
const { save, publish, load } = useWhpApi();
|
||||
const { headerPage, footerPage } = usePages();
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// Load saved state on mount
|
||||
useEffect(() => {
|
||||
if (!isWHP || hasLoadedRef.current) return;
|
||||
hasLoadedRef.current = true;
|
||||
|
||||
load().catch((e) => {
|
||||
console.warn('Failed to load project from WHP API:', e);
|
||||
});
|
||||
}, [isWHP, load]);
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
useEffect(() => {
|
||||
if (!isWHP) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
save()
|
||||
.then((result) => {
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent fail for auto-save
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isWHP, save]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const result = await save();
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2500);
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
}, [save]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
setPublishStatus('publishing');
|
||||
try {
|
||||
const result = await publish();
|
||||
if (result?.success) {
|
||||
setPublishStatus('published');
|
||||
setIsDraft(false);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
} else {
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Publish failed:', e);
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
}
|
||||
}, [publish]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="topbar">
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn">
|
||||
<i className="fa fa-arrow-left" /> Back to Panel
|
||||
</a>
|
||||
)}
|
||||
<span className="topbar-title">Site Builder</span>
|
||||
{isWHP && (
|
||||
<span className="topbar-domain">{whpConfig!.siteDomain}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="topbar-center">
|
||||
<div className="device-switcher">
|
||||
{(['desktop', 'tablet', 'mobile'] as DeviceMode[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`device-btn ${device === d ? 'active' : ''}`}
|
||||
onClick={() => onDeviceChange(d)}
|
||||
title={d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
>
|
||||
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo">
|
||||
<i className="fa fa-undo" />
|
||||
</button>
|
||||
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo">
|
||||
<i className="fa fa-repeat" />
|
||||
</button>
|
||||
<span className="topbar-divider" />
|
||||
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<i className="fa fa-th-large" /> Templates
|
||||
</button>
|
||||
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
|
||||
<i className="fa fa-code" /> Code
|
||||
</button>
|
||||
<button className="topbar-btn" title="Preview" onClick={() => {
|
||||
try {
|
||||
const serialized = query.serialize();
|
||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
||||
// Get header HTML
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerPage.craftState) {
|
||||
headerHtml = exportBodyHtml(headerPage.craftState).html;
|
||||
}
|
||||
} catch (e) { console.warn('Header export failed:', e); }
|
||||
|
||||
// Get page body HTML
|
||||
const bodyResult = exportBodyHtml(serialized);
|
||||
const bodyHtml = bodyResult.html;
|
||||
|
||||
// Get footer HTML
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerPage.craftState) {
|
||||
footerHtml = exportBodyHtml(footerPage.craftState).html;
|
||||
}
|
||||
} catch (e) { console.warn('Footer export failed:', e); }
|
||||
|
||||
// Compose full page: header + body + footer
|
||||
const composedBody = headerHtml + bodyHtml + footerHtml;
|
||||
const result = exportToHtml(serialized, {
|
||||
title: whpConfig?.siteName || 'Preview',
|
||||
includeFonts: true,
|
||||
});
|
||||
|
||||
// Replace the body in the full document with our composed version
|
||||
let html = result.html;
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
if (bodyMatch) {
|
||||
html = html.replace(bodyMatch[1], composedBody);
|
||||
}
|
||||
|
||||
// Make proxy URLs absolute so they work from the blob: context
|
||||
const origin = window.location.origin;
|
||||
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
|
||||
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Preview failed:', e);
|
||||
}
|
||||
}}>
|
||||
<i className="fa fa-eye" /> Preview
|
||||
</button>
|
||||
|
||||
{/* Draft/Published status badge */}
|
||||
{isWHP && isDraft && publishStatus !== 'published' && (
|
||||
<span className="publish-badge draft">
|
||||
<i className="fa fa-pencil" /> Draft
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'published' && (
|
||||
<span className="publish-badge published">
|
||||
<i className="fa fa-check-circle" /> Published
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save status indicator */}
|
||||
{saveStatus === 'saved' && (
|
||||
<span className="save-indicator saved">
|
||||
<i className="fa fa-check" /> Saved!
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Save Error
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Publish Error
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="topbar-btn primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
title="Save Draft"
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
||||
) : (
|
||||
<><i className="fa fa-save" /> Save</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isWHP && (
|
||||
<button
|
||||
className="topbar-btn publish"
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
title="Publish to live site"
|
||||
>
|
||||
{publishStatus === 'publishing' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
||||
) : (
|
||||
<><i className="fa fa-globe" /> Publish</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
22
craft/src/state/EditorConfigContext.tsx
Normal file
22
craft/src/state/EditorConfigContext.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { WhpConfig } from '../types';
|
||||
|
||||
interface EditorConfigContextValue {
|
||||
whpConfig: WhpConfig | null;
|
||||
isWHP: boolean;
|
||||
}
|
||||
|
||||
const EditorConfigContext = createContext<EditorConfigContextValue>({
|
||||
whpConfig: null,
|
||||
isWHP: false,
|
||||
});
|
||||
|
||||
export const useEditorConfig = () => useContext(EditorConfigContext);
|
||||
|
||||
export const EditorConfigProvider: React.FC<{ config: WhpConfig | null; children: ReactNode }> = ({ config, children }) => {
|
||||
return (
|
||||
<EditorConfigContext.Provider value={{ whpConfig: config, isWHP: !!config }}>
|
||||
{children}
|
||||
</EditorConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
300
craft/src/state/PageContext.tsx
Normal file
300
craft/src/state/PageContext.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { PageData } from '../types';
|
||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
||||
|
||||
interface PageContextValue {
|
||||
pages: PageData[];
|
||||
headerPage: PageData;
|
||||
footerPage: PageData;
|
||||
activePageId: string;
|
||||
isEditingHeader: boolean;
|
||||
isEditingFooter: boolean;
|
||||
switchPage: (pageId: string) => void;
|
||||
editHeader: () => void;
|
||||
editFooter: () => void;
|
||||
addPage: (name: string, slug: string) => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
renamePage: (pageId: string, name: string, slug: string) => void;
|
||||
setHeaderCraftState: (craftState: string) => void;
|
||||
setFooterCraftState: (craftState: string) => void;
|
||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
||||
siteDesign: SiteDesign;
|
||||
}
|
||||
|
||||
const HEADER_ID = '__header__';
|
||||
const FOOTER_ID = '__footer__';
|
||||
|
||||
const EMPTY_CANVAS =
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
const EMPTY_HEADER =
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#ffffff","padding":"12px 24px","display":"flex","alignItems":"center"},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
const EMPTY_FOOTER =
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
const PageContext = createContext<PageContextValue>({
|
||||
pages: [],
|
||||
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' },
|
||||
footerPage: { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null, headCode: '' },
|
||||
activePageId: 'home',
|
||||
isEditingHeader: false,
|
||||
isEditingFooter: false,
|
||||
switchPage: () => {},
|
||||
editHeader: () => {},
|
||||
editFooter: () => {},
|
||||
addPage: () => {},
|
||||
deletePage: () => {},
|
||||
renamePage: () => {},
|
||||
setHeaderCraftState: () => {},
|
||||
setFooterCraftState: () => {},
|
||||
setPagesCraftState: () => {},
|
||||
siteDesign: {} as SiteDesign,
|
||||
});
|
||||
|
||||
export const usePages = () => useContext(PageContext);
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE: PageData = {
|
||||
id: 'home',
|
||||
name: 'Home',
|
||||
slug: 'index',
|
||||
craftState: null,
|
||||
headCode: '',
|
||||
};
|
||||
|
||||
const DEFAULT_HEADER: PageData = {
|
||||
id: HEADER_ID,
|
||||
name: 'Header',
|
||||
slug: '__header__',
|
||||
craftState: null,
|
||||
headCode: '',
|
||||
};
|
||||
|
||||
const DEFAULT_FOOTER: PageData = {
|
||||
id: FOOTER_ID,
|
||||
name: 'Footer',
|
||||
slug: '__footer__',
|
||||
craftState: null,
|
||||
headCode: '',
|
||||
};
|
||||
|
||||
export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { query, actions } = useEditor();
|
||||
const { design } = useSiteDesign();
|
||||
const [pages, setPages] = useState<PageData[]>([DEFAULT_PAGE]);
|
||||
const [headerPage, setHeaderPage] = useState<PageData>(DEFAULT_HEADER);
|
||||
const [footerPage, setFooterPage] = useState<PageData>(DEFAULT_FOOTER);
|
||||
const [activePageId, setActivePageId] = useState('home');
|
||||
const activePageIdRef = useRef(activePageId);
|
||||
activePageIdRef.current = activePageId;
|
||||
|
||||
const isEditingHeader = activePageId === HEADER_ID;
|
||||
const isEditingFooter = activePageId === FOOTER_ID;
|
||||
|
||||
/** Save whatever is on the current Frame back to the right state slot */
|
||||
const saveCurrentState = useCallback(() => {
|
||||
const currentState = query.serialize();
|
||||
const currentId = activePageIdRef.current;
|
||||
|
||||
if (currentId === HEADER_ID) {
|
||||
setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
|
||||
} else if (currentId === FOOTER_ID) {
|
||||
setFooterPage((prev) => ({ ...prev, craftState: currentState }));
|
||||
} else {
|
||||
setPages((prev) =>
|
||||
prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
|
||||
);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
/** Load a craft state into the Frame */
|
||||
const loadState = useCallback(
|
||||
(craftState: string | null, fallback: string) => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
actions.deserialize(craftState || fallback);
|
||||
} catch (e) {
|
||||
console.error('Failed to deserialize state:', e);
|
||||
try {
|
||||
actions.deserialize(fallback);
|
||||
} catch (_e2) {
|
||||
// give up
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
const switchPage = useCallback(
|
||||
(pageId: string) => {
|
||||
if (pageId === activePageIdRef.current) return;
|
||||
|
||||
// Serialize the current Craft.js state synchronously BEFORE switching
|
||||
const currentState = query.serialize();
|
||||
const currentId = activePageIdRef.current;
|
||||
|
||||
// Persist the serialized state to the correct page slot
|
||||
if (currentId === HEADER_ID) {
|
||||
setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
|
||||
} else if (currentId === FOOTER_ID) {
|
||||
setFooterPage((prev) => ({ ...prev, craftState: currentState }));
|
||||
} else {
|
||||
setPages((prev) =>
|
||||
prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
|
||||
);
|
||||
}
|
||||
|
||||
// Load target page state.
|
||||
// For header/footer we need the latest saved state. Since setState above is async,
|
||||
// read from the ref-like state getter. For header/footer, we read the current
|
||||
// state value and fall back to what we just saved if the target is the same slot.
|
||||
if (pageId === HEADER_ID) {
|
||||
// Use functional state read to get the latest value
|
||||
setHeaderPage((prev) => {
|
||||
loadState(prev.craftState, EMPTY_HEADER);
|
||||
return prev;
|
||||
});
|
||||
} else if (pageId === FOOTER_ID) {
|
||||
setFooterPage((prev) => {
|
||||
loadState(prev.craftState, EMPTY_FOOTER);
|
||||
return prev;
|
||||
});
|
||||
} else {
|
||||
setPages((prev) => {
|
||||
const target = prev.find((p) => p.id === pageId);
|
||||
loadState(target?.craftState || null, EMPTY_CANVAS);
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
setActivePageId(pageId);
|
||||
activePageIdRef.current = pageId;
|
||||
},
|
||||
[query, loadState],
|
||||
);
|
||||
|
||||
const editHeader = useCallback(() => {
|
||||
switchPage(HEADER_ID);
|
||||
}, [switchPage]);
|
||||
|
||||
const editFooter = useCallback(() => {
|
||||
switchPage(FOOTER_ID);
|
||||
}, [switchPage]);
|
||||
|
||||
const addPage = useCallback(
|
||||
(name: string, slug: string) => {
|
||||
const finalSlug = slug || slugify(name);
|
||||
const id = `page_${Date.now()}`;
|
||||
|
||||
// Save current page first
|
||||
saveCurrentState();
|
||||
|
||||
setPages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name,
|
||||
slug: finalSlug,
|
||||
craftState: null,
|
||||
headCode: '',
|
||||
},
|
||||
]);
|
||||
|
||||
// Switch to the new page with empty canvas
|
||||
loadState(null, EMPTY_CANVAS);
|
||||
|
||||
setActivePageId(id);
|
||||
activePageIdRef.current = id;
|
||||
},
|
||||
[saveCurrentState, loadState],
|
||||
);
|
||||
|
||||
const deletePage = useCallback(
|
||||
(pageId: string) => {
|
||||
// Can't delete header/footer
|
||||
if (pageId === HEADER_ID || pageId === FOOTER_ID) return;
|
||||
|
||||
setPages((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
|
||||
const filtered = prev.filter((p) => p.id !== pageId);
|
||||
|
||||
// If deleting the active page, switch to the first remaining
|
||||
if (pageId === activePageIdRef.current) {
|
||||
const nextPage = filtered[0];
|
||||
setActivePageId(nextPage.id);
|
||||
activePageIdRef.current = nextPage.id;
|
||||
loadState(nextPage.craftState, EMPTY_CANVAS);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
},
|
||||
[loadState],
|
||||
);
|
||||
|
||||
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
||||
setPages((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === pageId ? { ...p, name, slug: slug || slugify(name) } : p,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Allow external code (e.g., load from API) to set the header craft state */
|
||||
const setHeaderCraftState = useCallback((craftState: string) => {
|
||||
setHeaderPage((prev) => ({ ...prev, craftState }));
|
||||
}, []);
|
||||
|
||||
/** Allow external code (e.g., load from API) to set the footer craft state */
|
||||
const setFooterCraftState = useCallback((craftState: string) => {
|
||||
setFooterPage((prev) => ({ ...prev, craftState }));
|
||||
}, []);
|
||||
|
||||
/** Allow external code (e.g., load from API) to restore pages with craft states */
|
||||
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
|
||||
setPages(pagesData.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
craftState: p.craftState,
|
||||
headCode: '',
|
||||
})));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContext.Provider
|
||||
value={{
|
||||
pages,
|
||||
headerPage,
|
||||
footerPage,
|
||||
activePageId,
|
||||
isEditingHeader,
|
||||
isEditingFooter,
|
||||
switchPage,
|
||||
editHeader,
|
||||
editFooter,
|
||||
addPage,
|
||||
deletePage,
|
||||
renamePage,
|
||||
setHeaderCraftState,
|
||||
setFooterCraftState,
|
||||
setPagesCraftState,
|
||||
siteDesign: design,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PageContext.Provider>
|
||||
);
|
||||
};
|
||||
81
craft/src/state/SiteDesignContext.tsx
Normal file
81
craft/src/state/SiteDesignContext.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
export interface SiteDesign {
|
||||
// Basic
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
accentColor: string;
|
||||
headingFont: string;
|
||||
bodyFont: string;
|
||||
linkColor: string;
|
||||
|
||||
// Advanced
|
||||
successColor: string;
|
||||
warningColor: string;
|
||||
errorColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
mutedTextColor: string;
|
||||
borderColor: string;
|
||||
borderRadius: string;
|
||||
buttonFont: string;
|
||||
buttonRadius: string;
|
||||
navStyle: 'light' | 'dark';
|
||||
|
||||
// Site-wide custom code
|
||||
headCode: string;
|
||||
}
|
||||
|
||||
export interface SiteDesignContextValue {
|
||||
design: SiteDesign;
|
||||
updateDesign: (updates: Partial<SiteDesign>) => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_SITE_DESIGN: SiteDesign = {
|
||||
primaryColor: '#3b82f6',
|
||||
secondaryColor: '#8b5cf6',
|
||||
accentColor: '#10b981',
|
||||
headingFont: 'Inter, sans-serif',
|
||||
bodyFont: 'Inter, sans-serif',
|
||||
linkColor: '#3b82f6',
|
||||
|
||||
successColor: '#10b981',
|
||||
warningColor: '#f59e0b',
|
||||
errorColor: '#ef4444',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#1f2937',
|
||||
mutedTextColor: '#6b7280',
|
||||
borderColor: '#e5e7eb',
|
||||
borderRadius: '8px',
|
||||
buttonFont: 'Inter, sans-serif',
|
||||
buttonRadius: '8px',
|
||||
navStyle: 'light',
|
||||
headCode: '',
|
||||
};
|
||||
|
||||
const SiteDesignContext = createContext<SiteDesignContextValue>({
|
||||
design: DEFAULT_SITE_DESIGN,
|
||||
updateDesign: () => {},
|
||||
resetToDefaults: () => {},
|
||||
});
|
||||
|
||||
export const useSiteDesign = () => useContext(SiteDesignContext);
|
||||
|
||||
export const SiteDesignProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [design, setDesign] = useState<SiteDesign>(DEFAULT_SITE_DESIGN);
|
||||
|
||||
const updateDesign = useCallback((updates: Partial<SiteDesign>) => {
|
||||
setDesign((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
setDesign(DEFAULT_SITE_DESIGN);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteDesignContext.Provider value={{ design, updateDesign, resetToDefaults }}>
|
||||
{children}
|
||||
</SiteDesignContext.Provider>
|
||||
);
|
||||
};
|
||||
1308
craft/src/styles/editor.css
Normal file
1308
craft/src/styles/editor.css
Normal file
File diff suppressed because it is too large
Load Diff
2957
craft/src/templates/definitions.ts
Normal file
2957
craft/src/templates/definitions.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
craft/src/templates/index.ts
Normal file
9
craft/src/templates/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
TemplateDefinition,
|
||||
TemplateComponent,
|
||||
TemplatePageContent,
|
||||
TemplatePageDef,
|
||||
TemplateCategory,
|
||||
} from './definitions';
|
||||
|
||||
export { allTemplates } from './definitions';
|
||||
34
craft/src/types/index.ts
Normal file
34
craft/src/types/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
export interface WhpConfig {
|
||||
user: string;
|
||||
apiUrl: string;
|
||||
csrfToken: string;
|
||||
siteId: number;
|
||||
siteDomain: string;
|
||||
siteName: string;
|
||||
backUrl: string;
|
||||
isRoot: boolean;
|
||||
}
|
||||
|
||||
export interface PageData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
craftState: string | null;
|
||||
headCode: string;
|
||||
}
|
||||
|
||||
export interface AssetData {
|
||||
name: string;
|
||||
url: string;
|
||||
type: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
export interface StyleProps {
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export type DeviceMode = 'desktop' | 'tablet' | 'mobile';
|
||||
233
craft/src/ui/AdvancedTab.tsx
Normal file
233
craft/src/ui/AdvancedTab.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { SpacingInput, SpacingValue, parseSpacingShorthand, spacingToShorthand } from './SpacingInput';
|
||||
|
||||
interface AdvancedTabProps {
|
||||
style: CSSProperties;
|
||||
onStyleChange: (updates: CSSProperties) => void;
|
||||
/** Optional: current CSS ID value */
|
||||
cssId?: string;
|
||||
onCssIdChange?: (id: string) => void;
|
||||
/** Optional: current CSS class value */
|
||||
cssClass?: string;
|
||||
onCssClassChange?: (cls: string) => void;
|
||||
/** Optional: show HTML tag selector (for containers) */
|
||||
showTagSelector?: boolean;
|
||||
tag?: string;
|
||||
onTagChange?: (tag: string) => void;
|
||||
/** Responsive visibility */
|
||||
hideOnDesktop?: boolean;
|
||||
onHideOnDesktopChange?: (hide: boolean) => void;
|
||||
hideOnTablet?: boolean;
|
||||
onHideOnTabletChange?: (hide: boolean) => void;
|
||||
hideOnMobile?: boolean;
|
||||
onHideOnMobileChange?: (hide: boolean) => void;
|
||||
/** Entrance animation */
|
||||
animation?: string;
|
||||
onAnimationChange?: (anim: string) => void;
|
||||
animationDelay?: string;
|
||||
onAnimationDelayChange?: (delay: string) => void;
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||||
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const TAG_OPTIONS = ['div', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav'];
|
||||
|
||||
export const AdvancedTab: React.FC<AdvancedTabProps> = ({
|
||||
style,
|
||||
onStyleChange,
|
||||
cssId = '',
|
||||
onCssIdChange,
|
||||
cssClass = '',
|
||||
onCssClassChange,
|
||||
showTagSelector = false,
|
||||
tag,
|
||||
onTagChange,
|
||||
hideOnDesktop = false,
|
||||
onHideOnDesktopChange,
|
||||
hideOnTablet = false,
|
||||
onHideOnTabletChange,
|
||||
hideOnMobile = false,
|
||||
onHideOnMobileChange,
|
||||
animation = 'none',
|
||||
onAnimationChange,
|
||||
animationDelay = '0',
|
||||
onAnimationDelayChange,
|
||||
}) => {
|
||||
// Parse margin and padding from style
|
||||
const margin: SpacingValue = {
|
||||
top: (style.marginTop as string) || '0',
|
||||
right: (style.marginRight as string) || '0',
|
||||
bottom: (style.marginBottom as string) || '0',
|
||||
left: (style.marginLeft as string) || '0',
|
||||
};
|
||||
|
||||
// If there's a shorthand margin, parse it
|
||||
const marginShorthand = style.margin as string | undefined;
|
||||
const resolvedMargin = marginShorthand ? parseSpacingShorthand(marginShorthand) : margin;
|
||||
|
||||
const padding: SpacingValue = {
|
||||
top: (style.paddingTop as string) || '0',
|
||||
right: (style.paddingRight as string) || '0',
|
||||
bottom: (style.paddingBottom as string) || '0',
|
||||
left: (style.paddingLeft as string) || '0',
|
||||
};
|
||||
|
||||
const paddingShorthand = style.padding as string | undefined;
|
||||
const resolvedPadding = paddingShorthand ? parseSpacingShorthand(paddingShorthand) : padding;
|
||||
|
||||
const handleMarginChange = (val: SpacingValue) => {
|
||||
onStyleChange({
|
||||
margin: spacingToShorthand(val),
|
||||
marginTop: undefined,
|
||||
marginRight: undefined,
|
||||
marginBottom: undefined,
|
||||
marginLeft: undefined,
|
||||
} as CSSProperties);
|
||||
};
|
||||
|
||||
const handlePaddingChange = (val: SpacingValue) => {
|
||||
onStyleChange({
|
||||
padding: spacingToShorthand(val),
|
||||
paddingTop: undefined,
|
||||
paddingRight: undefined,
|
||||
paddingBottom: undefined,
|
||||
paddingLeft: undefined,
|
||||
} as CSSProperties);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Margin */}
|
||||
<SpacingInput
|
||||
label="Margin"
|
||||
value={resolvedMargin}
|
||||
onChange={handleMarginChange}
|
||||
/>
|
||||
|
||||
{/* Padding */}
|
||||
<SpacingInput
|
||||
label="Padding"
|
||||
value={resolvedPadding}
|
||||
onChange={handlePaddingChange}
|
||||
/>
|
||||
|
||||
{/* HTML Tag (for containers) */}
|
||||
{showTagSelector && onTagChange && (
|
||||
<div>
|
||||
<label style={labelStyle}>HTML Tag</label>
|
||||
<select
|
||||
value={tag || 'div'}
|
||||
onChange={(e) => onTagChange(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
{TAG_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}><{t}></option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS ID */}
|
||||
{onCssIdChange && (
|
||||
<div>
|
||||
<label style={labelStyle}>CSS ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cssId}
|
||||
onChange={(e) => onCssIdChange(e.target.value.replace(/\s/g, '-'))}
|
||||
placeholder="my-element"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS Class */}
|
||||
{onCssClassChange && (
|
||||
<div>
|
||||
<label style={labelStyle}>CSS Class</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cssClass}
|
||||
onChange={(e) => onCssClassChange(e.target.value)}
|
||||
placeholder="class-one class-two"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Responsive Visibility */}
|
||||
{(onHideOnDesktopChange || onHideOnTabletChange || onHideOnMobileChange) && (
|
||||
<div>
|
||||
<label style={labelStyle}>Visibility</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{([
|
||||
{ key: 'hideOnDesktop' as const, label: 'Desktop', icon: 'fa-desktop', value: hideOnDesktop, onChange: onHideOnDesktopChange },
|
||||
{ key: 'hideOnTablet' as const, label: 'Tablet', icon: 'fa-tablet', value: hideOnTablet, onChange: onHideOnTabletChange },
|
||||
{ key: 'hideOnMobile' as const, label: 'Mobile', icon: 'fa-mobile', value: hideOnMobile, onChange: onHideOnMobileChange },
|
||||
] as const).map(({ key, label, icon, value, onChange }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!value}
|
||||
onChange={(e) => onChange && onChange(!e.target.checked)}
|
||||
/>
|
||||
<i className={`fa ${icon}`} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entrance Animation */}
|
||||
{onAnimationChange && (
|
||||
<div>
|
||||
<label style={labelStyle}>Entrance Animation</label>
|
||||
<select
|
||||
value={animation || 'none'}
|
||||
onChange={(e) => onAnimationChange(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="fade-in">Fade In</option>
|
||||
<option value="slide-up">Slide Up</option>
|
||||
<option value="slide-left">Slide from Left</option>
|
||||
<option value="slide-right">Slide from Right</option>
|
||||
<option value="zoom-in">Zoom In</option>
|
||||
<option value="bounce">Bounce</option>
|
||||
</select>
|
||||
{animation && animation !== 'none' && onAnimationDelayChange && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<label style={labelStyle}>Delay</label>
|
||||
<select
|
||||
value={animationDelay || '0'}
|
||||
onChange={(e) => onAnimationDelayChange(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="0">None</option>
|
||||
<option value="0.2s">0.2s</option>
|
||||
<option value="0.4s">0.4s</option>
|
||||
<option value="0.6s">0.6s</option>
|
||||
<option value="0.8s">0.8s</option>
|
||||
<option value="1s">1s</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
218
craft/src/ui/BorderControl.tsx
Normal file
218
craft/src/ui/BorderControl.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
interface BorderControlProps {
|
||||
style: CSSProperties;
|
||||
onChange: (updates: CSSProperties) => void;
|
||||
}
|
||||
|
||||
const BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted'] as const;
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||||
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
|
||||
};
|
||||
|
||||
const btnStyle = (active: boolean): React.CSSProperties => ({
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
flex: 1, textTransform: 'capitalize',
|
||||
});
|
||||
|
||||
const sideLabel: React.CSSProperties = {
|
||||
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
interface FourSidedValue {
|
||||
top: string;
|
||||
right: string;
|
||||
bottom: string;
|
||||
left: string;
|
||||
}
|
||||
|
||||
function parseFourSided(val: string | undefined): FourSidedValue {
|
||||
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
|
||||
const parts = val.trim().split(/\s+/);
|
||||
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
|
||||
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
|
||||
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
|
||||
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
|
||||
}
|
||||
|
||||
function fourSidedToString(val: FourSidedValue): string {
|
||||
const { top, right, bottom, left } = val;
|
||||
if (top === right && right === bottom && bottom === left) return top || '0';
|
||||
if (top === bottom && right === left) return `${top} ${right}`;
|
||||
if (right === left) return `${top} ${right} ${bottom}`;
|
||||
return `${top} ${right} ${bottom} ${left}`;
|
||||
}
|
||||
|
||||
function getNumeric(val: string): string {
|
||||
return val.replace(/[^0-9.]/g, '') || '0';
|
||||
}
|
||||
|
||||
export const BorderControl: React.FC<BorderControlProps> = ({ style, onChange }) => {
|
||||
const [widthLinked, setWidthLinked] = useState(true);
|
||||
const [radiusLinked, setRadiusLinked] = useState(true);
|
||||
|
||||
const currentBorderStyle = (style.borderStyle as string) || 'none';
|
||||
const currentBorderColor = (style.borderColor as string) || '#3f3f46';
|
||||
|
||||
// Border width as 4-sided
|
||||
const borderWidth = parseFourSided(style.borderWidth as string);
|
||||
|
||||
// Border radius as 4 corners (TL, TR, BR, BL)
|
||||
const borderRadius = parseFourSided(style.borderRadius as string);
|
||||
|
||||
const handleWidthChange = (side: keyof FourSidedValue, raw: string) => {
|
||||
const num = raw.replace(/[^0-9.]/g, '');
|
||||
const newVal = num ? `${num}px` : '0';
|
||||
let updated: FourSidedValue;
|
||||
if (widthLinked) {
|
||||
updated = { top: newVal, right: newVal, bottom: newVal, left: newVal };
|
||||
} else {
|
||||
updated = { ...borderWidth, [side]: newVal };
|
||||
}
|
||||
onChange({ borderWidth: fourSidedToString(updated) });
|
||||
};
|
||||
|
||||
const handleRadiusChange = (corner: keyof FourSidedValue, raw: string) => {
|
||||
const num = raw.replace(/[^0-9.]/g, '');
|
||||
const newVal = num ? `${num}px` : '0';
|
||||
let updated: FourSidedValue;
|
||||
if (radiusLinked) {
|
||||
updated = { top: newVal, right: newVal, bottom: newVal, left: newVal };
|
||||
} else {
|
||||
updated = { ...borderRadius, [corner]: newVal };
|
||||
}
|
||||
onChange({ borderRadius: fourSidedToString(updated) });
|
||||
};
|
||||
|
||||
const widthSides: { key: keyof FourSidedValue; label: string }[] = [
|
||||
{ key: 'top', label: 'T' },
|
||||
{ key: 'right', label: 'R' },
|
||||
{ key: 'bottom', label: 'B' },
|
||||
{ key: 'left', label: 'L' },
|
||||
];
|
||||
|
||||
const radiusCorners: { key: keyof FourSidedValue; label: string }[] = [
|
||||
{ key: 'top', label: 'TL' },
|
||||
{ key: 'right', label: 'TR' },
|
||||
{ key: 'bottom', label: 'BR' },
|
||||
{ key: 'left', label: 'BL' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Border Style */}
|
||||
<div>
|
||||
<label style={labelStyle}>Border Style</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{BORDER_STYLES.map((bs) => (
|
||||
<button
|
||||
key={bs}
|
||||
onClick={() => onChange({ borderStyle: bs })}
|
||||
style={btnStyle(currentBorderStyle === bs)}
|
||||
>{bs}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border Width (4-sided) */}
|
||||
{currentBorderStyle !== 'none' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>Border Width</label>
|
||||
<button
|
||||
onClick={() => setWidthLinked(!widthLinked)}
|
||||
title={widthLinked ? 'Unlink sides' : 'Link all sides'}
|
||||
style={{
|
||||
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: widthLinked ? '#3b82f6' : '#27272a',
|
||||
color: widthLinked ? '#fff' : '#71717a',
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${widthLinked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
|
||||
{widthSides.map((s) => (
|
||||
<div key={s.key}>
|
||||
<input
|
||||
type="text"
|
||||
value={getNumeric(borderWidth[s.key])}
|
||||
onChange={(e) => handleWidthChange(s.key, e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={sideLabel}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Border Color */}
|
||||
{currentBorderStyle !== 'none' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Border Color</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={currentBorderColor}
|
||||
onChange={(e) => onChange({ borderColor: e.target.value })}
|
||||
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentBorderColor}
|
||||
onChange={(e) => onChange({ borderColor: e.target.value })}
|
||||
style={{ ...inputStyle, flex: 1, textAlign: 'left' }}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Border Radius (4 corners) */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>Border Radius</label>
|
||||
<button
|
||||
onClick={() => setRadiusLinked(!radiusLinked)}
|
||||
title={radiusLinked ? 'Unlink corners' : 'Link all corners'}
|
||||
style={{
|
||||
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: radiusLinked ? '#3b82f6' : '#27272a',
|
||||
color: radiusLinked ? '#fff' : '#71717a',
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${radiusLinked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
|
||||
{radiusCorners.map((c) => (
|
||||
<div key={c.key}>
|
||||
<input
|
||||
type="text"
|
||||
value={getNumeric(borderRadius[c.key])}
|
||||
onChange={(e) => handleRadiusChange(c.key, e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={sideLabel}>{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
craft/src/ui/SettingsTabs.tsx
Normal file
26
craft/src/ui/SettingsTabs.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface SettingsTabsProps {
|
||||
general: React.ReactNode;
|
||||
style: React.ReactNode;
|
||||
advanced: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SettingsTabs: React.FC<SettingsTabsProps> = ({ general, style, advanced }) => {
|
||||
const [tab, setTab] = useState<'general' | 'style' | 'advanced'>('general');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-tabs">
|
||||
<button className={tab === 'general' ? 'active' : ''} onClick={() => setTab('general')}>General</button>
|
||||
<button className={tab === 'style' ? 'active' : ''} onClick={() => setTab('style')}>Style</button>
|
||||
<button className={tab === 'advanced' ? 'active' : ''} onClick={() => setTab('advanced')}>Advanced</button>
|
||||
</div>
|
||||
<div className="settings-content">
|
||||
{tab === 'general' && general}
|
||||
{tab === 'style' && style}
|
||||
{tab === 'advanced' && advanced}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
141
craft/src/ui/SpacingInput.tsx
Normal file
141
craft/src/ui/SpacingInput.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export interface SpacingValue {
|
||||
top: string;
|
||||
right: string;
|
||||
bottom: string;
|
||||
left: string;
|
||||
}
|
||||
|
||||
interface SpacingInputProps {
|
||||
label: string;
|
||||
value: SpacingValue;
|
||||
onChange: (value: SpacingValue) => void;
|
||||
}
|
||||
|
||||
const UNITS = ['px', 'em', '%'] as const;
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||||
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
|
||||
};
|
||||
|
||||
const sideLabel: React.CSSProperties = {
|
||||
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
function parseValue(val: string): { num: string; unit: string } {
|
||||
const match = val.match(/^(-?\d*\.?\d+)\s*(px|em|%|rem)?$/);
|
||||
if (match) return { num: match[1], unit: match[2] || 'px' };
|
||||
return { num: val.replace(/[^0-9.-]/g, '') || '0', unit: 'px' };
|
||||
}
|
||||
|
||||
export const SpacingInput: React.FC<SpacingInputProps> = ({ label, value, onChange }) => {
|
||||
const [linked, setLinked] = useState(false);
|
||||
const [unit, setUnit] = useState<string>(() => parseValue(value.top).unit || 'px');
|
||||
|
||||
const handleSideChange = (side: keyof SpacingValue, raw: string) => {
|
||||
const numericPart = raw.replace(/[^0-9.-]/g, '');
|
||||
const newVal = numericPart ? `${numericPart}${unit}` : '0';
|
||||
|
||||
if (linked) {
|
||||
onChange({ top: newVal, right: newVal, bottom: newVal, left: newVal });
|
||||
} else {
|
||||
onChange({ ...value, [side]: newVal });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnitChange = (newUnit: string) => {
|
||||
setUnit(newUnit);
|
||||
// Re-apply current numeric values with new unit
|
||||
const updated: SpacingValue = { top: '', right: '', bottom: '', left: '' };
|
||||
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
|
||||
const { num } = parseValue(value[side]);
|
||||
updated[side] = num && num !== '0' ? `${num}${newUnit}` : '0';
|
||||
}
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const sides: { key: keyof SpacingValue; label: string }[] = [
|
||||
{ key: 'top', label: 'T' },
|
||||
{ key: 'right', label: 'R' },
|
||||
{ key: 'bottom', label: 'B' },
|
||||
{ key: 'left', label: 'L' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>{label}</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Unit selector */}
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{UNITS.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => handleUnitChange(u)}
|
||||
style={{
|
||||
padding: '1px 5px', fontSize: 9, borderRadius: 3, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: unit === u ? '#3b82f6' : '#27272a',
|
||||
color: unit === u ? '#fff' : '#71717a',
|
||||
}}
|
||||
>{u}</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Link toggle */}
|
||||
<button
|
||||
onClick={() => setLinked(!linked)}
|
||||
title={linked ? 'Unlink sides' : 'Link all sides'}
|
||||
style={{
|
||||
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: linked ? '#3b82f6' : '#27272a',
|
||||
color: linked ? '#fff' : '#71717a',
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${linked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
|
||||
{sides.map((s) => (
|
||||
<div key={s.key}>
|
||||
<input
|
||||
type="text"
|
||||
value={parseValue(value[s.key]).num}
|
||||
onChange={(e) => handleSideChange(s.key, e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={sideLabel}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Parse a CSS shorthand like "10px 20px 10px 20px" or "10px" into SpacingValue */
|
||||
export function parseSpacingShorthand(val: string | undefined): SpacingValue {
|
||||
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
|
||||
const parts = val.trim().split(/\s+/);
|
||||
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
|
||||
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
|
||||
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
|
||||
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
|
||||
}
|
||||
|
||||
/** Convert SpacingValue back to CSS shorthand */
|
||||
export function spacingToShorthand(val: SpacingValue): string {
|
||||
const { top, right, bottom, left } = val;
|
||||
if (top === right && right === bottom && bottom === left) return top || '0';
|
||||
if (top === bottom && right === left) return `${top} ${right}`;
|
||||
if (right === left) return `${top} ${right} ${bottom}`;
|
||||
return `${top} ${right} ${bottom} ${left}`;
|
||||
}
|
||||
221
craft/src/ui/TypographyControl.tsx
Normal file
221
craft/src/ui/TypographyControl.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React from 'react';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
interface TypographyControlProps {
|
||||
style: CSSProperties;
|
||||
onChange: (updates: CSSProperties) => void;
|
||||
}
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
{ label: 'Inter', value: 'Inter, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ label: 'Open Sans', value: 'Open Sans, 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' },
|
||||
];
|
||||
|
||||
const FONT_WEIGHTS = [
|
||||
{ label: 'Light', value: '300' },
|
||||
{ label: 'Normal', value: '400' },
|
||||
{ label: 'Medium', value: '500' },
|
||||
{ label: 'Semi', value: '600' },
|
||||
{ label: 'Bold', value: '700' },
|
||||
];
|
||||
|
||||
const SIZE_UNITS = ['px', 'em', 'rem'] as const;
|
||||
|
||||
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
|
||||
{ label: 'Aa', value: 'none' },
|
||||
{ label: 'AA', value: 'uppercase' },
|
||||
{ label: 'aa', value: 'lowercase' },
|
||||
{ label: 'Aa', value: 'capitalize' },
|
||||
];
|
||||
|
||||
const TEXT_ALIGNS = ['left', 'center', 'right', 'justify'] as const;
|
||||
const ALIGN_ICONS: Record<string, string> = {
|
||||
left: 'fa-align-left',
|
||||
center: 'fa-align-center',
|
||||
right: 'fa-align-right',
|
||||
justify: 'fa-align-justify',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||||
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const btnStyle = (active: boolean): React.CSSProperties => ({
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
});
|
||||
|
||||
function parseSizeValue(val: string | number | undefined): { num: string; unit: string } {
|
||||
if (val === undefined || val === '') return { num: '', unit: 'px' };
|
||||
const s = String(val);
|
||||
const match = s.match(/^(-?\d*\.?\d+)\s*(px|em|rem|%)?$/);
|
||||
if (match) return { num: match[1], unit: match[2] || 'px' };
|
||||
return { num: s.replace(/[^0-9.-]/g, ''), unit: 'px' };
|
||||
}
|
||||
|
||||
export const TypographyControl: React.FC<TypographyControlProps> = ({ style, onChange }) => {
|
||||
const currentFamily = (style.fontFamily as string) || '';
|
||||
const currentWeight = String(style.fontWeight || '');
|
||||
const currentAlign = (style.textAlign as string) || '';
|
||||
const currentTransform = (style.textTransform as string) || 'none';
|
||||
const currentColor = (style.color as string) || '#1f2937';
|
||||
|
||||
const fontSize = parseSizeValue(style.fontSize);
|
||||
const lineHeight = String(style.lineHeight || '');
|
||||
const letterSpacing = String(style.letterSpacing || '');
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Font Family */}
|
||||
<div>
|
||||
<label style={labelStyle}>Font Family</label>
|
||||
<select
|
||||
value={currentFamily}
|
||||
onChange={(e) => onChange({ fontFamily: e.target.value })}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Font Weight */}
|
||||
<div>
|
||||
<label style={labelStyle}>Font Weight</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{FONT_WEIGHTS.map((w) => (
|
||||
<button
|
||||
key={w.value}
|
||||
onClick={() => onChange({ fontWeight: w.value })}
|
||||
style={{ ...btnStyle(currentWeight === w.value), flex: 1 }}
|
||||
>{w.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Size + Line Height row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Font Size</label>
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={fontSize.num}
|
||||
onChange={(e) => {
|
||||
const num = e.target.value.replace(/[^0-9.]/g, '');
|
||||
onChange({ fontSize: num ? `${num}${fontSize.unit}` : '' });
|
||||
}}
|
||||
placeholder="16"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<select
|
||||
value={fontSize.unit}
|
||||
onChange={(e) => {
|
||||
const newUnit = e.target.value;
|
||||
onChange({ fontSize: fontSize.num ? `${fontSize.num}${newUnit}` : '' });
|
||||
}}
|
||||
style={{ ...selectStyle, width: 52, padding: '4px 2px', fontSize: 10 }}
|
||||
>
|
||||
{SIZE_UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Line Height</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lineHeight}
|
||||
onChange={(e) => onChange({ lineHeight: e.target.value })}
|
||||
placeholder="1.6"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Letter Spacing */}
|
||||
<div>
|
||||
<label style={labelStyle}>Letter Spacing</label>
|
||||
<input
|
||||
type="text"
|
||||
value={letterSpacing}
|
||||
onChange={(e) => onChange({ letterSpacing: e.target.value })}
|
||||
placeholder="0px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text Transform */}
|
||||
<div>
|
||||
<label style={labelStyle}>Text Transform</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{TEXT_TRANSFORMS.map((t, i) => (
|
||||
<button
|
||||
key={`${t.value}-${i}`}
|
||||
onClick={() => onChange({ textTransform: t.value as CSSProperties['textTransform'] })}
|
||||
style={{ ...btnStyle(currentTransform === t.value), flex: 1, fontStyle: t.value === 'none' ? 'italic' : undefined }}
|
||||
title={t.value}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Align */}
|
||||
<div>
|
||||
<label style={labelStyle}>Text Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{TEXT_ALIGNS.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => onChange({ textAlign: a as CSSProperties['textAlign'] })}
|
||||
style={{ ...btnStyle(currentAlign === a), flex: 1 }}
|
||||
title={a}
|
||||
>
|
||||
<i className={`fa ${ALIGN_ICONS[a]}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={currentColor}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentColor}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
243
craft/src/utils/html-export.ts
Normal file
243
craft/src/utils/html-export.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { componentResolver } from '../components/resolver';
|
||||
import { cssPropsToString } from './style-helpers';
|
||||
|
||||
export interface ExportOptions {
|
||||
title?: string;
|
||||
includeFonts?: boolean;
|
||||
minifyCss?: boolean;
|
||||
headCode?: string;
|
||||
}
|
||||
|
||||
interface ResolverMap {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const resolver: ResolverMap = componentResolver;
|
||||
|
||||
/**
|
||||
* Build data attribute string for responsive visibility and animations.
|
||||
*/
|
||||
function buildDataAttrs(props: Record<string, any>): string {
|
||||
let attrs = '';
|
||||
if (props.hideOnDesktop) attrs += ' data-hide-desktop';
|
||||
if (props.hideOnTablet) attrs += ' data-hide-tablet';
|
||||
if (props.hideOnMobile) attrs += ' data-hide-mobile';
|
||||
if (props.animation && props.animation !== 'none') {
|
||||
attrs += ` data-animation="${props.animation}"`;
|
||||
if (props.animationDelay && props.animationDelay !== '0') {
|
||||
attrs += ` data-animation-delay="${props.animationDelay}"`;
|
||||
}
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject data attributes into the first HTML opening tag of a rendered string.
|
||||
*/
|
||||
function injectAttrs(html: string, attrs: string): string {
|
||||
if (!attrs) return html;
|
||||
// Find the first > of the opening tag and inject before it
|
||||
const idx = html.indexOf('>');
|
||||
if (idx === -1) return html;
|
||||
return html.slice(0, idx) + attrs + html.slice(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively render a Craft.js node tree to HTML.
|
||||
*/
|
||||
function renderNode(nodes: Record<string, any>, nodeId: string): { html: string } {
|
||||
const node = nodes[nodeId];
|
||||
if (!node) return { html: '' };
|
||||
|
||||
const typeName = node.type?.resolvedName || node.type;
|
||||
const props = node.props || {};
|
||||
|
||||
// Collect children HTML
|
||||
const childNodeIds: string[] = node.nodes || [];
|
||||
const linkedNodes: Record<string, string> = node.linkedNodes || {};
|
||||
|
||||
// Render direct child nodes
|
||||
let childrenHtml = childNodeIds
|
||||
.map((childId: string) => renderNode(nodes, childId).html)
|
||||
.join('');
|
||||
|
||||
// Render linked nodes (e.g., Section's inner container)
|
||||
const linkedHtml = Object.values(linkedNodes)
|
||||
.map((linkedId: string) => renderNode(nodes, linkedId).html)
|
||||
.join('');
|
||||
|
||||
// For linked nodes, the component's toHtml should handle them via childrenHtml
|
||||
// We prioritize linked nodes if direct children are empty
|
||||
const allChildrenHtml = childrenHtml + linkedHtml;
|
||||
|
||||
// Build data attributes for responsive visibility and animations
|
||||
const dataAttrs = buildDataAttrs(props);
|
||||
|
||||
// Look up component in resolver and call toHtml
|
||||
const component = resolver[typeName];
|
||||
if (component && typeof component.toHtml === 'function') {
|
||||
const result = component.toHtml(props, allChildrenHtml);
|
||||
const html = result.html || '';
|
||||
return { html: injectAttrs(html, dataAttrs) };
|
||||
}
|
||||
|
||||
// Fallback: wrap children in a div with inline styles
|
||||
if (typeName === 'Container' || typeName === 'div') {
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
const tag = props.tag || 'div';
|
||||
return {
|
||||
html: `<${tag}${dataAttrs}${styleStr ? ` style="${styleStr}"` : ''}>${allChildrenHtml}</${tag}>`,
|
||||
};
|
||||
}
|
||||
|
||||
// For unrecognized types, just return children
|
||||
return { html: allChildrenHtml };
|
||||
}
|
||||
|
||||
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
|
||||
|
||||
const CSS_RESET_PRETTY = `*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}`;
|
||||
|
||||
const GOOGLE_FONTS_LINK = `<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">`;
|
||||
|
||||
const RESPONSIVE_CSS = `
|
||||
@media (max-width: 768px) {
|
||||
[style*="display: flex"][style*="flex-direction: row"],
|
||||
[style*="display:flex"][style*="flex-direction:row"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
}`;
|
||||
|
||||
const RESPONSIVE_CSS_MINIFIED = `@media(max-width:768px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
|
||||
|
||||
const VISIBILITY_CSS = `
|
||||
@media (min-width: 992px) { [data-hide-desktop] { display: none !important; } }
|
||||
@media (min-width: 768px) and (max-width: 991px) { [data-hide-tablet] { display: none !important; } }
|
||||
@media (max-width: 767px) { [data-hide-mobile] { display: none !important; } }`;
|
||||
|
||||
const VISIBILITY_CSS_MINIFIED = `@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}`;
|
||||
|
||||
const ANIMATION_CSS = `
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes bounce { 0% { opacity: 0; transform: translateY(30px); } 60% { opacity: 1; transform: translateY(-5px); } 100% { transform: translateY(0); } }
|
||||
|
||||
[data-animation] { opacity: 0; }
|
||||
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
|
||||
[data-animation="fade-in"].animated { animation-name: fadeIn; }
|
||||
[data-animation="slide-up"].animated { animation-name: slideUp; }
|
||||
[data-animation="slide-left"].animated { animation-name: slideLeft; }
|
||||
[data-animation="slide-right"].animated { animation-name: slideRight; }
|
||||
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
|
||||
[data-animation="bounce"].animated { animation-name: bounce; }`;
|
||||
|
||||
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(30px)}60%{opacity:1;transform:translateY(-5px)}100%{transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
|
||||
|
||||
const ANIMATION_SCRIPT = `<script>
|
||||
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
||||
var delay = el.getAttribute('data-animation-delay');
|
||||
if (delay) el.style.animationDelay = delay;
|
||||
new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
|
||||
}, { threshold: 0.1 }).observe(el);
|
||||
});
|
||||
</script>`;
|
||||
|
||||
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
const title = options.title || 'Untitled Page';
|
||||
const minify = options.minifyCss !== false;
|
||||
const reset = minify ? CSS_RESET : CSS_RESET_PRETTY;
|
||||
const responsive = minify ? RESPONSIVE_CSS_MINIFIED : RESPONSIVE_CSS;
|
||||
const visibility = minify ? VISIBILITY_CSS_MINIFIED : VISIBILITY_CSS;
|
||||
const animation = minify ? ANIMATION_CSS_MINIFIED : ANIMATION_CSS;
|
||||
const fonts = options.includeFonts !== false ? `\n ${GOOGLE_FONTS_LINK}` : '';
|
||||
const headCode = options.headCode ? `\n ${options.headCode}` : '';
|
||||
|
||||
// Only include animation CSS + script if body contains data-animation
|
||||
const hasAnimations = bodyHtml.includes('data-animation');
|
||||
const animationBlock = hasAnimations ? animation : '';
|
||||
const animationScript = hasAnimations ? `\n${ANIMATION_SCRIPT}` : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)}</title>${fonts}
|
||||
<style>${reset}${responsive}${visibility}${animationBlock}</style>${headCode}
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}${animationScript}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export serialized Craft.js state to standalone HTML.
|
||||
*/
|
||||
/**
|
||||
* Export as a full HTML document (for preview).
|
||||
*/
|
||||
export function exportToHtml(
|
||||
serializedState: string,
|
||||
options: ExportOptions = {},
|
||||
): { html: string; css: string } {
|
||||
try {
|
||||
const nodes = JSON.parse(serializedState);
|
||||
const { html: bodyHtml } = renderNode(nodes, 'ROOT');
|
||||
const fullHtml = wrapInDocument(bodyHtml, options);
|
||||
return { html: fullHtml, css: '' };
|
||||
} catch (e) {
|
||||
console.error('Export to HTML failed:', e);
|
||||
return {
|
||||
html: wrapInDocument('<p>Export failed. Please try again.</p>', options),
|
||||
css: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export just the body HTML + CSS (for WHP API save -- PHP wraps in document).
|
||||
*/
|
||||
export function exportBodyHtml(
|
||||
serializedState: string,
|
||||
): { html: string; css: string } {
|
||||
try {
|
||||
const nodes = JSON.parse(serializedState);
|
||||
const { html } = renderNode(nodes, 'ROOT');
|
||||
return { html, css: '' };
|
||||
} catch (e) {
|
||||
console.error('Body export failed:', e);
|
||||
return { html: '', css: '' };
|
||||
}
|
||||
}
|
||||
16
craft/src/utils/style-helpers.ts
Normal file
16
craft/src/utils/style-helpers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
const camelToKebab = (str: string): string =>
|
||||
str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
||||
|
||||
export function cssPropsToString(style: CSSProperties | undefined): string {
|
||||
if (!style) return '';
|
||||
return Object.entries(style)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.map(([k, v]) => `${camelToKebab(k)}:${v}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
export function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties {
|
||||
return Object.assign({}, ...styles.filter(Boolean));
|
||||
}
|
||||
Reference in New Issue
Block a user