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:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions

View 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>
);
};

View 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)',
}}
>
&#128196;
</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'; }}
>
&#10005;
</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>
);
};

View 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>
);
};

View 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)' }}>
&#9660;
</span>
)}
{allChildren.length === 0 && (
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
&#9660;
</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>
);
};

View 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>
);
};

View 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',
}}
>
&#9998;
</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',
}}
>
&#10005;
</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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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';

View 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>
);
};

View 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 &lt;head&gt; 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 }}>&lt;head&gt;</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',
};

View 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">
&#10005;
</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)',
};

View 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>
);
};