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:
283
craft/src/panels/context-menu/ContextMenu.tsx
Normal file
283
craft/src/panels/context-menu/ContextMenu.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
|
||||
interface ContextMenuProps {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
dividerAfter?: boolean;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
visible,
|
||||
x,
|
||||
y,
|
||||
nodeId,
|
||||
onClose,
|
||||
}) => {
|
||||
const { actions, query } = useEditor();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const clipboardRef = useRef<string | null>(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
const getParentId = useCallback((): string | null => {
|
||||
if (!nodeId) return null;
|
||||
try {
|
||||
const node = query.node(nodeId).get();
|
||||
return node?.data?.parent || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [nodeId, query]);
|
||||
|
||||
const duplicate = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
const tree = query.node(nodeId).toSerializedNode();
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
|
||||
// Get the full subtree
|
||||
const freshTree = query.node(nodeId).toNodeTree();
|
||||
const clonedTree = query.parseSerializedNode(freshTree.nodes[freshTree.rootNodeId].data).toNode();
|
||||
|
||||
actions.addNodeTree(freshTree, parentId);
|
||||
} catch (e) {
|
||||
console.error('Duplicate failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
|
||||
const copyNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
clipboardRef.current = nodeId;
|
||||
} catch (e) {
|
||||
console.error('Copy failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, onClose]);
|
||||
|
||||
const pasteNode = useCallback(() => {
|
||||
const sourceId = clipboardRef.current;
|
||||
if (!sourceId) return;
|
||||
try {
|
||||
const targetParent = nodeId || 'ROOT';
|
||||
const tree = query.node(sourceId).toNodeTree();
|
||||
actions.addNodeTree(tree, targetParent);
|
||||
} catch (e) {
|
||||
console.error('Paste failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, onClose]);
|
||||
|
||||
const moveUp = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
const parent = query.node(parentId).get();
|
||||
const children = parent.data.nodes || [];
|
||||
const idx = children.indexOf(nodeId);
|
||||
if (idx > 0) {
|
||||
actions.move(nodeId, parentId, idx - 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Move up failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
|
||||
const moveDown = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
const parent = query.node(parentId).get();
|
||||
const children = parent.data.nodes || [];
|
||||
const idx = children.indexOf(nodeId);
|
||||
if (idx < children.length - 1) {
|
||||
actions.move(nodeId, parentId, idx + 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Move down failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
|
||||
const selectParent = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
const parentId = getParentId();
|
||||
if (parentId) {
|
||||
actions.selectNode(parentId);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, getParentId, onClose]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
actions.delete(nodeId);
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, onClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const isRoot = nodeId === 'ROOT' || !nodeId;
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: 'Duplicate',
|
||||
shortcut: 'Ctrl+D',
|
||||
action: duplicate,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
shortcut: 'Ctrl+C',
|
||||
action: copyNode,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Paste',
|
||||
shortcut: 'Ctrl+V',
|
||||
action: pasteNode,
|
||||
disabled: !clipboardRef.current,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: 'Move Up',
|
||||
action: moveUp,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Move Down',
|
||||
action: moveDown,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Select Parent',
|
||||
action: selectParent,
|
||||
disabled: isRoot,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
shortcut: 'Del',
|
||||
action: deleteNode,
|
||||
danger: true,
|
||||
disabled: isRoot,
|
||||
},
|
||||
];
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
const adjustedX = Math.min(x, window.innerWidth - 200);
|
||||
const adjustedY = Math.min(y, window.innerHeight - items.length * 34 - 10);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: adjustedY,
|
||||
left: adjustedX,
|
||||
zIndex: 10000,
|
||||
minWidth: 180,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
padding: '4px 0',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.label}>
|
||||
<button
|
||||
onClick={item.disabled ? undefined : item.action}
|
||||
disabled={item.disabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '7px 12px',
|
||||
fontSize: 12,
|
||||
color: item.disabled
|
||||
? 'var(--color-text-dim)'
|
||||
: item.danger
|
||||
? 'var(--color-danger)'
|
||||
: 'var(--color-text)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: item.disabled ? 'default' : 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!item.disabled) {
|
||||
(e.target as HTMLElement).style.background = 'var(--color-bg-hover)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
marginLeft: 16,
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{item.dividerAfter && (
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: 'var(--color-border)',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
249
craft/src/panels/left/AssetsPanel.tsx
Normal file
249
craft/src/panels/left/AssetsPanel.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAssets } from '../../hooks/useAssets';
|
||||
|
||||
export const AssetsPanel: React.FC = () => {
|
||||
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadAssets();
|
||||
}, [loadAssets]);
|
||||
|
||||
const handleFileSelect = useCallback(async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await uploadAsset(files[i]);
|
||||
}
|
||||
}, [uploadAsset]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}, [handleFileSelect]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const copyUrl = useCallback((url: string) => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedUrl(url);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isImage = (type: string) =>
|
||||
type.startsWith('image/') || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(type);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{/* Upload button */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: loading ? 'var(--color-bg-active)' : 'var(--color-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Uploading...' : 'Upload File'}
|
||||
</button>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
style={{
|
||||
padding: 20,
|
||||
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
textAlign: 'center',
|
||||
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
||||
fontSize: 11,
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
Drop files here to upload
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-danger)',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
{assets.length === 0 && !loading && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 20,
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
No assets uploaded yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color var(--transition-fast)',
|
||||
}}
|
||||
onClick={() => copyUrl(asset.url)}
|
||||
title={`Click to copy URL: ${asset.url}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--color-bg-base)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isImage(asset.type) ? (
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={asset.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: 'var(--color-text-dim)',
|
||||
}}
|
||||
>
|
||||
📄
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{copiedUrl === asset.url ? 'Copied!' : asset.name}
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteAsset(asset.name);
|
||||
}}
|
||||
title="Delete asset"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
color: '#fff',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && assets.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
211
craft/src/panels/left/BlocksPanel.tsx
Normal file
211
craft/src/panels/left/BlocksPanel.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { Container } from '../../components/layout/Container';
|
||||
import { Section } from '../../components/layout/Section';
|
||||
import { ColumnLayout } from '../../components/layout/ColumnLayout';
|
||||
import { BackgroundSection } from '../../components/layout/BackgroundSection';
|
||||
import { Heading } from '../../components/basic/Heading';
|
||||
import { TextBlock } from '../../components/basic/TextBlock';
|
||||
import { ButtonLink } from '../../components/basic/ButtonLink';
|
||||
import { Logo } from '../../components/basic/Logo';
|
||||
import { Menu } from '../../components/basic/Menu';
|
||||
import { Footer } from '../../components/basic/Footer';
|
||||
import { Divider } from '../../components/basic/Divider';
|
||||
import { Spacer } from '../../components/basic/Spacer';
|
||||
import { Icon } from '../../components/basic/Icon';
|
||||
import { HtmlBlock } from '../../components/basic/HtmlBlock';
|
||||
import { ImageBlock } from '../../components/media/ImageBlock';
|
||||
import { VideoBlock } from '../../components/media/VideoBlock';
|
||||
import { MapEmbed } from '../../components/media/MapEmbed';
|
||||
import { HeroSimple } from '../../components/sections/HeroSimple';
|
||||
import { FeaturesGrid } from '../../components/sections/FeaturesGrid';
|
||||
import { CallToAction } from '../../components/sections/CallToAction';
|
||||
import { Countdown } from '../../components/sections/Countdown';
|
||||
import { Testimonials } from '../../components/sections/Testimonials';
|
||||
import { Accordion } from '../../components/sections/Accordion';
|
||||
import { Tabs } from '../../components/sections/Tabs';
|
||||
import { PricingTable } from '../../components/sections/PricingTable';
|
||||
import { Gallery } from '../../components/sections/Gallery';
|
||||
import { ContentSlider } from '../../components/sections/ContentSlider';
|
||||
import { NumberCounter } from '../../components/sections/NumberCounter';
|
||||
import { FormContainer } from '../../components/forms/FormContainer';
|
||||
import { InputField } from '../../components/forms/InputField';
|
||||
import { TextareaField } from '../../components/forms/TextareaField';
|
||||
import { FormButton } from '../../components/forms/FormButton';
|
||||
import { ContactForm } from '../../components/forms/ContactForm';
|
||||
import { SubscribeForm } from '../../components/forms/SubscribeForm';
|
||||
import { StarRating } from '../../components/basic/StarRating';
|
||||
import { SocialLinks } from '../../components/basic/SocialLinks';
|
||||
|
||||
interface BlockDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactElement;
|
||||
}
|
||||
|
||||
interface CategoryDef {
|
||||
id: string;
|
||||
label: string;
|
||||
blocks: BlockDef[];
|
||||
}
|
||||
|
||||
const categories: CategoryDef[] = [
|
||||
{
|
||||
id: 'basic',
|
||||
label: 'Basic',
|
||||
blocks: [
|
||||
{ id: 'heading', label: 'Heading', icon: 'fa-header',
|
||||
component: <Heading text="Your Heading" level="h2" style={{ fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px' }} /> },
|
||||
{ id: 'text', label: 'Text', icon: 'fa-paragraph',
|
||||
component: <TextBlock text="Add your text here. Click to edit." style={{ fontSize: '16px', lineHeight: '1.6', color: '#4b5563', fontFamily: 'Inter, sans-serif' }} /> },
|
||||
{ id: 'button', label: 'Button', icon: 'fa-square',
|
||||
component: <ButtonLink text="Click Me" href="#" style={{ display: 'inline-block', padding: '14px 32px', background: '#3b82f6', color: '#ffffff', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '16px', fontFamily: 'Inter, sans-serif' }} /> },
|
||||
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', component: <Logo /> },
|
||||
{ id: 'menu', label: 'Menu', icon: 'fa-bars', component: <Menu /> },
|
||||
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', component: <Footer /> },
|
||||
{ id: 'divider', label: 'Divider', icon: 'fa-minus', component: <Divider /> },
|
||||
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', component: <Spacer /> },
|
||||
{ id: 'icon', label: 'Icon', icon: 'fa-star', component: <Icon icon="fa-star" size="48px" color="#3b82f6" /> },
|
||||
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', component: <StarRating /> },
|
||||
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', component: <SocialLinks /> },
|
||||
{ id: 'html-block', label: 'HTML', icon: 'fa-code', component: <HtmlBlock code="<div>Custom HTML</div>" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
label: 'Layout',
|
||||
blocks: [
|
||||
{ id: 'section', label: 'Section', icon: 'fa-window-maximize',
|
||||
component: <Section style={{ padding: '60px 20px', backgroundColor: '#ffffff' }} /> },
|
||||
{ id: 'container', label: 'Container', icon: 'fa-square-o',
|
||||
component: <Container style={{ padding: '20px', minHeight: '100px' }} /> },
|
||||
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop',
|
||||
component: <ColumnLayout columns={1} split="100" /> },
|
||||
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large',
|
||||
component: <ColumnLayout columns={2} split="50-50" /> },
|
||||
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={3} split="33-33-33" /> },
|
||||
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={4} split="25-25-25-25" /> },
|
||||
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={5} split="20-20-20-20-20" /> },
|
||||
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th',
|
||||
component: <ColumnLayout columns={6} split="16-16-16-16-16-16" /> },
|
||||
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns',
|
||||
component: <ColumnLayout columns={2} split="30-70" /> },
|
||||
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns',
|
||||
component: <ColumnLayout columns={2} split="70-30" /> },
|
||||
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', component: <BackgroundSection /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sections',
|
||||
label: 'Sections',
|
||||
blocks: [
|
||||
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', component: <HeroSimple /> },
|
||||
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', component: <FeaturesGrid /> },
|
||||
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', component: <CallToAction /> },
|
||||
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', component: <Accordion /> },
|
||||
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', component: <Tabs /> },
|
||||
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', component: <PricingTable /> },
|
||||
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', component: <Gallery /> },
|
||||
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o',
|
||||
component: <Countdown targetDate={new Date(Date.now() + 30 * 86400000).toISOString()} /> },
|
||||
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left',
|
||||
component: <Testimonials testimonials={[{ quote: "Great service!", name: "John", title: "CEO", rating: 5 }]} layout="grid" columns={3} /> },
|
||||
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', component: <ContentSlider /> },
|
||||
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', component: <NumberCounter /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
label: 'Media',
|
||||
blocks: [
|
||||
{ id: 'image', label: 'Image', icon: 'fa-image',
|
||||
component: <ImageBlock src="" alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
|
||||
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
|
||||
component: <VideoBlock videoUrl="" isBackground={false} /> },
|
||||
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
|
||||
component: <MapEmbed address="New York, NY" zoom={14} height="400px" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'forms',
|
||||
label: 'Forms',
|
||||
blocks: [
|
||||
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', component: <ContactForm /> },
|
||||
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', component: <SubscribeForm /> },
|
||||
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', component: <FormContainer /> },
|
||||
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', component: <InputField /> },
|
||||
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', component: <TextareaField /> },
|
||||
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', component: <FormButton /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const BlocksPanel: React.FC = () => {
|
||||
const { connectors, actions, query } = useEditor();
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
categories.forEach((cat, index) => {
|
||||
initial[cat.id] = index !== 0; // First category open
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{categories.map((category) => {
|
||||
const isCollapsed = collapsed[category.id];
|
||||
return (
|
||||
<div key={category.id} className="block-category">
|
||||
<div
|
||||
className={`block-category-header ${isCollapsed ? 'collapsed' : ''}`}
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
>
|
||||
<span>{category.label}</span>
|
||||
<i className="fa fa-chevron-down chevron" />
|
||||
</div>
|
||||
<div className={`block-category-items ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="block-grid">
|
||||
{category.blocks.map((block) => (
|
||||
<div
|
||||
key={block.id}
|
||||
className="block-item"
|
||||
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
|
||||
onDoubleClick={() => {
|
||||
try {
|
||||
const serialized = JSON.parse(query.serialize());
|
||||
const nodeIds = Object.keys(serialized);
|
||||
let canvasId = 'ROOT';
|
||||
for (const nodeId of nodeIds) {
|
||||
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
|
||||
canvasId = nodeId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
|
||||
actions.addNodeTree(tree, canvasId);
|
||||
} catch (e) {
|
||||
console.error('Failed to add block:', e);
|
||||
}
|
||||
}}
|
||||
title={`Drag or double-click to add ${block.label}`}
|
||||
>
|
||||
<i className={`fa ${block.icon} block-item-icon`} />
|
||||
<span className="block-item-label">{block.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
137
craft/src/panels/left/LayersPanel.tsx
Normal file
137
craft/src/panels/left/LayersPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
|
||||
interface LayerNodeProps {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
const { node, selectedId, actions } = useEditor((state) => {
|
||||
const n = state.nodes[nodeId];
|
||||
const selectedIds = state.events.selected;
|
||||
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
|
||||
return {
|
||||
node: n,
|
||||
selectedId: selId,
|
||||
};
|
||||
});
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
actions.selectNode(nodeId);
|
||||
}, [actions, nodeId]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const isSelected = selectedId === nodeId;
|
||||
const nodeType = node.data.type;
|
||||
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
|
||||
? (nodeType as any).resolvedName
|
||||
: typeof nodeType === 'string' ? nodeType : undefined;
|
||||
const displayName = node.data.displayName || resolvedName || 'Component';
|
||||
const childNodeIds: string[] = node.data.nodes || [];
|
||||
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
||||
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
||||
const isRoot = nodeId === 'ROOT';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '5px 8px',
|
||||
paddingLeft: `${8 + depth * 16}px`,
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
color: isSelected ? 'var(--color-accent)' : 'var(--color-text)',
|
||||
background: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
borderLeft: isSelected ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Indentation indicator */}
|
||||
{allChildren.length > 0 && (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)' }}>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
{allChildren.length === 0 && (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Component type icon and name */}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{isRoot ? 'Canvas (Root)' : displayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Render children */}
|
||||
{allChildren.map((childId) => (
|
||||
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LayersPanel: React.FC = () => {
|
||||
const { nodeIds } = useEditor((state) => {
|
||||
return {
|
||||
nodeIds: Object.keys(state.nodes),
|
||||
};
|
||||
});
|
||||
|
||||
const hasRoot = nodeIds.includes('ROOT');
|
||||
|
||||
if (!hasRoot) {
|
||||
return (
|
||||
<div className="panel-placeholder">
|
||||
No content on canvas
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '-12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: 'var(--color-text-muted)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
Component Tree
|
||||
</div>
|
||||
<LayerNode nodeId="ROOT" depth={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
craft/src/panels/left/LeftPanel.tsx
Normal file
40
craft/src/panels/left/LeftPanel.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlocksPanel } from './BlocksPanel';
|
||||
import { PagesPanel } from './PagesPanel';
|
||||
import { LayersPanel } from './LayersPanel';
|
||||
import { AssetsPanel } from './AssetsPanel';
|
||||
|
||||
type LeftTab = 'blocks' | 'pages' | 'layers' | 'assets';
|
||||
|
||||
export const LeftPanel: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<LeftTab>('blocks');
|
||||
|
||||
const tabs: { id: LeftTab; label: string }[] = [
|
||||
{ id: 'blocks', label: 'Blocks' },
|
||||
{ id: 'pages', label: 'Pages' },
|
||||
{ id: 'layers', label: 'Layers' },
|
||||
{ id: 'assets', label: 'Assets' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="panel-left">
|
||||
<div className="panel-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`panel-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="panel-content">
|
||||
{activeTab === 'blocks' && <BlocksPanel />}
|
||||
{activeTab === 'pages' && <PagesPanel />}
|
||||
{activeTab === 'layers' && <LayersPanel />}
|
||||
{activeTab === 'assets' && <AssetsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
470
craft/src/panels/left/PagesPanel.tsx
Normal file
470
craft/src/panels/left/PagesPanel.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
|
||||
export const PagesPanel: React.FC = () => {
|
||||
const {
|
||||
pages,
|
||||
activePageId,
|
||||
isEditingHeader,
|
||||
isEditingFooter,
|
||||
switchPage,
|
||||
editHeader,
|
||||
editFooter,
|
||||
addPage,
|
||||
deletePage,
|
||||
renamePage,
|
||||
} = usePages();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newSlug, setNewSlug] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editSlug, setEditSlug] = useState('');
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newName.trim()) return;
|
||||
addPage(newName.trim(), newSlug.trim());
|
||||
setNewName('');
|
||||
setNewSlug('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleRename = (pageId: string) => {
|
||||
if (!editName.trim()) return;
|
||||
renamePage(pageId, editName.trim(), editSlug.trim());
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleDelete = (pageId: string) => {
|
||||
deletePage(pageId);
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
const startEditing = (page: { id: string; name: string; slug: string }) => {
|
||||
setEditingId(page.id);
|
||||
setEditName(page.name);
|
||||
setEditSlug(page.slug);
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
const autoSlug = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
/* ---------- Zone button style ---------- */
|
||||
const zoneButtonStyle = (isActive: boolean): React.CSSProperties => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: isActive ? '#f59e0b' : '#fbbf24',
|
||||
background: isActive ? 'rgba(245, 158, 11, 0.15)' : 'rgba(245, 158, 11, 0.06)',
|
||||
border: `1px solid ${isActive ? 'rgba(245, 158, 11, 0.5)' : 'rgba(245, 158, 11, 0.2)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
textAlign: 'left' as const,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* Header/Footer zone buttons */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={editHeader}
|
||||
style={zoneButtonStyle(isEditingHeader)}
|
||||
>
|
||||
<i className="fa fa-window-maximize" style={{ fontSize: 13 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>Edit Header</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
|
||||
Appears on all pages
|
||||
</div>
|
||||
</div>
|
||||
{isEditingHeader && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
background: 'rgba(245, 158, 11, 0.25)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={editFooter}
|
||||
style={zoneButtonStyle(isEditingFooter)}
|
||||
>
|
||||
<i className="fa fa-window-minimize" style={{ fontSize: 13 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>Edit Footer</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
|
||||
Appears on all pages
|
||||
</div>
|
||||
</div>
|
||||
{isEditingFooter && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
background: 'rgba(245, 158, 11, 0.25)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section label for pages */}
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: 'var(--color-text-dim)',
|
||||
padding: '0 2px',
|
||||
}}>
|
||||
Pages
|
||||
</div>
|
||||
|
||||
{/* Page list */}
|
||||
{pages.map((page) => (
|
||||
<div key={page.id}>
|
||||
{editingId === page.id ? (
|
||||
/* Editing mode */
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-accent)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => {
|
||||
setEditName(e.target.value);
|
||||
setEditSlug(autoSlug(e.target.value));
|
||||
}}
|
||||
placeholder="Page name"
|
||||
className="control-input"
|
||||
style={{ fontSize: 12 }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(page.id);
|
||||
if (e.key === 'Escape') setEditingId(null);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editSlug}
|
||||
onChange={(e) => setEditSlug(e.target.value)}
|
||||
placeholder="page-slug"
|
||||
className="control-input"
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => handleRename(page.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: 'var(--color-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : deleteConfirmId === page.id ? (
|
||||
/* Delete confirmation */
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
|
||||
Delete "{page.name}"?
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => handleDelete(page.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: 'var(--color-danger)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal page item */
|
||||
<div
|
||||
onClick={() => switchPage(page.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 10px',
|
||||
background:
|
||||
page.id === activePageId
|
||||
? 'var(--color-accent-subtle)'
|
||||
: 'var(--color-bg-elevated)',
|
||||
border: `1px solid ${page.id === activePageId ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: page.id === activePageId ? 600 : 500,
|
||||
color:
|
||||
page.id === activePageId
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{page.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
/{page.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', gap: 4, flexShrink: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => startEditing(page)}
|
||||
title="Rename"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
{pages.length > 1 && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(page.id)}
|
||||
title="Delete"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add page section */}
|
||||
{isAdding ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
setNewName(e.target.value);
|
||||
setNewSlug(autoSlug(e.target.value));
|
||||
}}
|
||||
placeholder="Page name"
|
||||
className="control-input"
|
||||
style={{ fontSize: 12 }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
if (e.key === 'Escape') {
|
||||
setIsAdding(false);
|
||||
setNewName('');
|
||||
setNewSlug('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(e.target.value)}
|
||||
placeholder="page-slug"
|
||||
className="control-input"
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!newName.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: newName.trim() ? 'var(--color-accent)' : 'var(--color-bg-active)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: newName.trim() ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
Add Page
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewName('');
|
||||
setNewSlug('');
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-accent)',
|
||||
background: 'var(--color-accent-subtle)',
|
||||
border: '1px dashed var(--color-accent)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
+ Add Page
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
craft/src/panels/right/GuidedStyles.tsx
Normal file
155
craft/src/panels/right/GuidedStyles.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { componentResolver } from '../../components/resolver';
|
||||
import { SiteDesignPanel } from './SiteDesignPanel';
|
||||
import {
|
||||
TextStylePanel,
|
||||
ButtonStylePanel,
|
||||
ImageStylePanel,
|
||||
ContainerStylePanel,
|
||||
HeroStylePanel,
|
||||
NavStylePanel,
|
||||
MediaStylePanel,
|
||||
FormStylePanel,
|
||||
SocialStylePanel,
|
||||
SectionTypePanel,
|
||||
PricingStylePanel,
|
||||
BackgroundSectionStylePanel,
|
||||
GenericPropsEditor,
|
||||
} from './styles';
|
||||
|
||||
/* ================================================================
|
||||
IMPORTANT: None of these panels use useNode().
|
||||
All prop mutations go through useEditor().actions.setProp(nodeId, ...)
|
||||
so they work from outside the node's render tree.
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
Main GuidedStyles component
|
||||
================================================================ */
|
||||
|
||||
export const GuidedStyles: React.FC = () => {
|
||||
const resolverMap = componentResolver as Record<string, any>;
|
||||
|
||||
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
|
||||
const currentNodeId = state.events.selected
|
||||
? Array.from(state.events.selected)[0]
|
||||
: undefined;
|
||||
let selectedType: string | null = null;
|
||||
let nodeProps: Record<string, any> = {};
|
||||
let resolvedName: string | null = null;
|
||||
|
||||
if (currentNodeId) {
|
||||
const node = state.nodes[currentNodeId];
|
||||
if (node) {
|
||||
selectedType = node.data.displayName || node.data.name || null;
|
||||
nodeProps = node.data.props || {};
|
||||
const nodeType = node.data.type as any;
|
||||
if (nodeType && typeof nodeType === 'object' && nodeType.resolvedName) {
|
||||
resolvedName = nodeType.resolvedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selected: currentNodeId || null,
|
||||
selectedType,
|
||||
nodeProps,
|
||||
resolvedName,
|
||||
};
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
return <SiteDesignPanel />;
|
||||
}
|
||||
|
||||
// Determine which panel to show based on displayName
|
||||
const typeName = selectedType || '';
|
||||
|
||||
// ---- Type classification (covers ALL 40 component display names) ----
|
||||
const isText = /^heading$|^text$/i.test(typeName);
|
||||
const isButton = /^button$/i.test(typeName);
|
||||
const isImage = /^image$/i.test(typeName);
|
||||
const isBgSection = /^background section$/i.test(typeName);
|
||||
const isContainer = /^container$|^section$|^columns$|^header zone$|^footer zone$/i.test(typeName);
|
||||
const isHero = /hero/i.test(typeName);
|
||||
const isNav = /^menu$|^logo$|^navbar$|^footer$/i.test(typeName);
|
||||
const isMedia = /^video$|^gallery$|^map$|^content slider$/i.test(typeName);
|
||||
const isForm = /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i.test(typeName);
|
||||
const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName);
|
||||
const isPricing = /^pricing/i.test(typeName);
|
||||
const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName);
|
||||
// Utility types that need minimal controls
|
||||
const isUtility = /^divider$|^spacer$|^html$/i.test(typeName);
|
||||
|
||||
// Icon for the type badge
|
||||
const typeIcon = isText ? 'fa-font'
|
||||
: isButton ? 'fa-hand-pointer-o'
|
||||
: isImage ? 'fa-image'
|
||||
: isBgSection ? 'fa-picture-o'
|
||||
: isContainer ? 'fa-object-group'
|
||||
: isHero ? 'fa-star'
|
||||
: isNav ? 'fa-bars'
|
||||
: isMedia ? 'fa-play-circle'
|
||||
: isForm ? 'fa-wpforms'
|
||||
: isSocial ? 'fa-share-alt'
|
||||
: isSection ? 'fa-th-large'
|
||||
: isUtility ? 'fa-ellipsis-h'
|
||||
: 'fa-cube';
|
||||
|
||||
return (
|
||||
<div className="guided-styles">
|
||||
{/* Component type badge */}
|
||||
<div className="guided-section guided-type-header">
|
||||
<span className="guided-type-badge">
|
||||
<i className={`fa ${typeIcon}`} />
|
||||
{' '}{typeName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* TEXT */}
|
||||
{isText && <TextStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* BUTTON */}
|
||||
{isButton && <ButtonStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* IMAGE */}
|
||||
{isImage && <ImageStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* BACKGROUND SECTION */}
|
||||
{isBgSection && <BackgroundSectionStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* CONTAINER / SECTION / COLUMNS */}
|
||||
{isContainer && <ContainerStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* HERO */}
|
||||
{isHero && <HeroStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* NAV / MENU / LOGO / FOOTER */}
|
||||
{isNav && <NavStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* MEDIA (Video, Gallery, Map, Slider) */}
|
||||
{isMedia && <MediaStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* FORM (Form, Input, Textarea, Subscribe, Contact, FormButton, Search) */}
|
||||
{isForm && <FormStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* SOCIAL / ICON / STAR RATING */}
|
||||
{isSocial && <SocialStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* PRICING TABLE */}
|
||||
{isPricing && <PricingStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */}
|
||||
{isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||
|
||||
{/* UTILITY (Divider, Spacer, HTML) -- use generic but it works well for these */}
|
||||
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||
|
||||
{/* FALLBACK: Anything not matched above */}
|
||||
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && (
|
||||
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
craft/src/panels/right/RightPanel.tsx
Normal file
19
craft/src/panels/right/RightPanel.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { GuidedStyles } from './GuidedStyles';
|
||||
|
||||
/**
|
||||
* Right panel -- shows component settings when an element is selected,
|
||||
* or Site Design Tokens (including Head code) when nothing is selected.
|
||||
*/
|
||||
export const RightPanel: React.FC = () => {
|
||||
return (
|
||||
<div className="panel-right">
|
||||
<div className="panel-tabs">
|
||||
<button className="panel-tab active">Styles</button>
|
||||
</div>
|
||||
<div className="panel-content">
|
||||
<GuidedStyles />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
362
craft/src/panels/right/SiteDesignPanel.tsx
Normal file
362
craft/src/panels/right/SiteDesignPanel.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||
import { FONT_FAMILIES } from '../../constants/presets';
|
||||
|
||||
type DesignTab = 'basic' | 'advanced';
|
||||
|
||||
/* ---------- Color picker with preset swatches ---------- */
|
||||
|
||||
const COLOR_SWATCHES = [
|
||||
'#3b82f6', '#8b5cf6', '#10b981', '#ef4444',
|
||||
'#f59e0b', '#ec4899', '#06b6d4', '#f97316',
|
||||
];
|
||||
|
||||
const NEUTRAL_SWATCHES = [
|
||||
'#ffffff', '#f9fafb', '#e5e7eb', '#9ca3af',
|
||||
'#6b7280', '#374151', '#1f2937', '#111827',
|
||||
];
|
||||
|
||||
interface ColorFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
swatches?: string[];
|
||||
}
|
||||
|
||||
const ColorField: React.FC<ColorFieldProps> = ({ label, value, onChange, swatches = COLOR_SWATCHES }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">{label}</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
padding: 0,
|
||||
border: '2px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 11, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
|
||||
{swatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => onChange(c)}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: value === c ? '2px solid var(--color-accent)' : '2px solid var(--color-border)',
|
||||
background: c,
|
||||
cursor: 'pointer',
|
||||
boxShadow: value === c ? '0 0 0 2px var(--color-accent)' : 'none',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Font dropdown ---------- */
|
||||
|
||||
interface FontFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const FontField: React.FC<FontFieldProps> = ({ label, value, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">{label}</label>
|
||||
<select
|
||||
className="control-select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Border radius presets ---------- */
|
||||
|
||||
const BORDER_RADIUS_PRESETS = [
|
||||
{ label: '0', value: '0' },
|
||||
{ label: '4px', value: '4px' },
|
||||
{ label: '8px', value: '8px' },
|
||||
{ label: '12px', value: '12px' },
|
||||
{ label: '16px', value: '16px' },
|
||||
];
|
||||
|
||||
interface RadiusFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const RadiusField: React.FC<RadiusFieldProps> = ({ label, value, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">{label}</label>
|
||||
<div className="preset-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
{BORDER_RADIUS_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`preset-btn ${value === p.value ? 'active' : ''}`}
|
||||
onClick={() => onChange(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Nav style toggle ---------- */
|
||||
|
||||
interface NavStyleFieldProps {
|
||||
value: 'light' | 'dark';
|
||||
onChange: (value: 'light' | 'dark') => void;
|
||||
}
|
||||
|
||||
const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">Nav Style</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{(['light', 'dark'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`preset-btn ${value === s ? 'active' : ''}`}
|
||||
style={{ flex: 1, textTransform: 'capitalize' }}
|
||||
onClick={() => onChange(s)}
|
||||
>
|
||||
{s === 'light' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Main SiteDesignPanel ---------- */
|
||||
|
||||
export const SiteDesignPanel: React.FC = () => {
|
||||
const { design, updateDesign, resetToDefaults } = useSiteDesign();
|
||||
const [tab, setTab] = useState<DesignTab>('basic');
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<i className="fa fa-paint-brush" style={{ color: 'var(--color-accent)', fontSize: 14 }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Site Design Tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||
{(['basic', 'advanced'] as DesignTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: tab === t ? '#fff' : 'var(--color-text-muted)',
|
||||
background: tab === t ? 'var(--color-accent)' : 'var(--color-bg-elevated)',
|
||||
border: tab === t ? '1px solid var(--color-accent)' : '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Basic tab */}
|
||||
{tab === 'basic' && (
|
||||
<>
|
||||
<ColorField
|
||||
label="Primary Color"
|
||||
value={design.primaryColor}
|
||||
onChange={(v) => updateDesign({ primaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Secondary Color"
|
||||
value={design.secondaryColor}
|
||||
onChange={(v) => updateDesign({ secondaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent Color"
|
||||
value={design.accentColor}
|
||||
onChange={(v) => updateDesign({ accentColor: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Heading Font"
|
||||
value={design.headingFont}
|
||||
onChange={(v) => updateDesign({ headingFont: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Body Font"
|
||||
value={design.bodyFont}
|
||||
onChange={(v) => updateDesign({ bodyFont: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Link Color"
|
||||
value={design.linkColor}
|
||||
onChange={(v) => updateDesign({ linkColor: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Advanced tab */}
|
||||
{tab === 'advanced' && (
|
||||
<>
|
||||
<ColorField
|
||||
label="Primary Color"
|
||||
value={design.primaryColor}
|
||||
onChange={(v) => updateDesign({ primaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Secondary Color"
|
||||
value={design.secondaryColor}
|
||||
onChange={(v) => updateDesign({ secondaryColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent Color"
|
||||
value={design.accentColor}
|
||||
onChange={(v) => updateDesign({ accentColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Success Color"
|
||||
value={design.successColor}
|
||||
onChange={(v) => updateDesign({ successColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Warning Color"
|
||||
value={design.warningColor}
|
||||
onChange={(v) => updateDesign({ warningColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Error Color"
|
||||
value={design.errorColor}
|
||||
onChange={(v) => updateDesign({ errorColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Background Color"
|
||||
value={design.backgroundColor}
|
||||
onChange={(v) => updateDesign({ backgroundColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<ColorField
|
||||
label="Text Color"
|
||||
value={design.textColor}
|
||||
onChange={(v) => updateDesign({ textColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<ColorField
|
||||
label="Muted Text Color"
|
||||
value={design.mutedTextColor}
|
||||
onChange={(v) => updateDesign({ mutedTextColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<ColorField
|
||||
label="Border Color"
|
||||
value={design.borderColor}
|
||||
onChange={(v) => updateDesign({ borderColor: v })}
|
||||
swatches={NEUTRAL_SWATCHES}
|
||||
/>
|
||||
<FontField
|
||||
label="Heading Font"
|
||||
value={design.headingFont}
|
||||
onChange={(v) => updateDesign({ headingFont: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Body Font"
|
||||
value={design.bodyFont}
|
||||
onChange={(v) => updateDesign({ bodyFont: v })}
|
||||
/>
|
||||
<FontField
|
||||
label="Button Font"
|
||||
value={design.buttonFont}
|
||||
onChange={(v) => updateDesign({ buttonFont: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Link Color"
|
||||
value={design.linkColor}
|
||||
onChange={(v) => updateDesign({ linkColor: v })}
|
||||
/>
|
||||
<RadiusField
|
||||
label="Default Border Radius"
|
||||
value={design.borderRadius}
|
||||
onChange={(v) => updateDesign({ borderRadius: v })}
|
||||
/>
|
||||
<RadiusField
|
||||
label="Button Radius"
|
||||
value={design.buttonRadius}
|
||||
onChange={(v) => updateDesign({ buttonRadius: v })}
|
||||
/>
|
||||
<NavStyleField
|
||||
value={design.navStyle}
|
||||
onChange={(v) => updateDesign({ navStyle: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--color-border)' }}>
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
<p style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
textAlign: 'center',
|
||||
margin: '8px 0 0',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
Design tokens are saved with your project and applied to new components.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
GradientSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
uploadToWhp,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- BACKGROUND SECTION ---------- */
|
||||
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp('bgImage', url);
|
||||
}, [setProp]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background Image */}
|
||||
<CollapsibleSection title="Background Image">
|
||||
{nodeProps.bgImage && (
|
||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
|
||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste image URL..."
|
||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Background Color */}
|
||||
<CollapsibleSection title="Background Color">
|
||||
<ColorPickerField label="Background" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Overlay */}
|
||||
<CollapsibleSection title="Overlay">
|
||||
<ColorPickerField label="Overlay Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}%</label>
|
||||
<input type="range" min={0} max={100} value={Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}
|
||||
onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value) / 100)} style={{ width: '100%' }} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Layout */}
|
||||
<CollapsibleSection title="Layout" defaultOpen={false}>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Inner Max Width</label>
|
||||
<input type="text" value={nodeProps.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp('innerMaxWidth', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
88
craft/src/panels/right/styles/ButtonStylePanel.tsx
Normal file
88
craft/src/panels/right/styles/ButtonStylePanel.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
RADIUS_PRESETS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
autoTextColor,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- BUTTON ---------- */
|
||||
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
const setButtonColor = useCallback(
|
||||
(bgColor: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = {
|
||||
...props.style,
|
||||
backgroundColor: bgColor,
|
||||
color: autoTextColor(bgColor),
|
||||
};
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Button Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={BG_COLORS}
|
||||
activeValue={style.backgroundColor as string}
|
||||
onSelect={setButtonColor}
|
||||
/>
|
||||
</div>
|
||||
<TextInputField
|
||||
label="Button Text"
|
||||
value={nodeProps.text || ''}
|
||||
placeholder="Click Me"
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.text = v; });
|
||||
}}
|
||||
/>
|
||||
<TextInputField
|
||||
label="Link URL"
|
||||
value={nodeProps.href || ''}
|
||||
placeholder="https://..."
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.href = v; });
|
||||
}}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={SPACING_PRESETS}
|
||||
activeValue={style.padding as string}
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
80
craft/src/panels/right/styles/ContainerStylePanel.tsx
Normal file
80
craft/src/panels/right/styles/ContainerStylePanel.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
GradientSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- CONTAINER / SECTION ---------- */
|
||||
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={BG_COLORS}
|
||||
activeValue={style.backgroundColor as string}
|
||||
onSelect={(v) => setPropStyle('backgroundColor', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background Gradient</SectionLabel>
|
||||
<GradientSwatchGrid
|
||||
activeValue={style.background as string}
|
||||
onSelect={(v) => setPropStyle('background', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={SPACING_PRESETS}
|
||||
activeValue={style.padding as string}
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
|
||||
onClick={() => setPropStyle('textAlign', a)}
|
||||
title={a}
|
||||
>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
139
craft/src/panels/right/styles/FormStylePanel.tsx
Normal file
139
craft/src/panels/right/styles/FormStylePanel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- FORM ---------- */
|
||||
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Form action/method */}
|
||||
{nodeProps.action !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Form Action URL</label>
|
||||
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.method !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Method</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['GET', 'POST'].map((m) => (
|
||||
<button key={m} onClick={() => setProp('method', m)} style={btnActiveStyle(nodeProps.method === m)}>{m}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field props */}
|
||||
{nodeProps.label !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Label</label>
|
||||
<input type="text" value={nodeProps.label || ''} onChange={(e) => setProp('label', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.placeholder !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input type="text" value={nodeProps.placeholder || ''} onChange={(e) => setProp('placeholder', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.name !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Field Name</label>
|
||||
<input type="text" value={nodeProps.name || ''} onChange={(e) => setProp('name', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.type !== undefined && typeof nodeProps.type === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Input Type</label>
|
||||
<select value={nodeProps.type} onChange={(e) => setProp('type', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
{['text', 'email', 'password', 'number', 'tel', 'url'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.required !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.required || false} onChange={(e) => setProp('required', e.target.checked)} />
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button text */}
|
||||
{nodeProps.text !== undefined && nodeProps.label === undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscribe form props */}
|
||||
{nodeProps.buttonText !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.placeholderText !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Placeholder Text</label>
|
||||
<input type="text" value={nodeProps.placeholderText || ''} onChange={(e) => setProp('placeholderText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.successMessage !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Success Message</label>
|
||||
<input type="text" value={nodeProps.successMessage || ''} onChange={(e) => setProp('successMessage', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
251
craft/src/panels/right/styles/GenericPropsEditor.tsx
Normal file
251
craft/src/panels/right/styles/GenericPropsEditor.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
||||
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
||||
selectedId, nodeProps, typeName,
|
||||
}) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
|
||||
const setPropValue = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setStyleValue = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
// Categorize all props
|
||||
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
|
||||
const colorProps = allProps.filter(([key, val]) => typeof val === 'string' && /color/i.test(key));
|
||||
const boolProps = allProps.filter(([_, val]) => typeof val === 'boolean');
|
||||
const numberProps = allProps.filter(([key, val]) => typeof val === 'number' && !/color/i.test(key));
|
||||
const stringProps = allProps.filter(([key, val]) => typeof val === 'string' && !/color/i.test(key));
|
||||
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* String props */}
|
||||
{stringProps.length > 0 && (
|
||||
<CollapsibleSection title="Properties">
|
||||
{stringProps.map(([key, val]) => {
|
||||
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
|
||||
const isLong = String(val).length > 60 || key === 'description' || key === 'text' || key === 'content';
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
{isLong ? (
|
||||
<textarea value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Number props */}
|
||||
{numberProps.length > 0 && (
|
||||
<CollapsibleSection title="Numbers" defaultOpen={true}>
|
||||
{numberProps.map(([key, val]) => (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||
<input type="number" value={val as number} onChange={(e) => setPropValue(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Boolean props */}
|
||||
{boolProps.length > 0 && (
|
||||
<CollapsibleSection title="Options" defaultOpen={true}>
|
||||
{boolProps.map(([key, val]) => (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={val as boolean} onChange={(e) => setPropValue(key, e.target.checked)} />
|
||||
{key.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Color props */}
|
||||
{colorProps.length > 0 && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{colorProps.map(([key, val]) => (
|
||||
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropValue(key, v)} />
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Array props */}
|
||||
{arrayProps.map(([key, items]) => {
|
||||
const arrayItems = items as any[];
|
||||
const sampleItem = arrayItems[0] || {};
|
||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||
|
||||
return (
|
||||
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey={key}
|
||||
items={arrayItems}
|
||||
renderItem={(item: any, index: number) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(item)}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = e.target.value;
|
||||
props[key] = updated;
|
||||
});
|
||||
}}
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{itemFields.map((field) => {
|
||||
const fieldVal = item[field];
|
||||
if (typeof fieldVal === 'boolean') {
|
||||
return (
|
||||
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.checked };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} />
|
||||
{field}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (typeof fieldVal === 'number') {
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
<input type="number" value={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (/color/i.test(field) && typeof fieldVal === 'string') {
|
||||
return (
|
||||
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
|
||||
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const strVal = String(fieldVal ?? '');
|
||||
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
{isLongField ? (
|
||||
<textarea value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
|
||||
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Style controls */}
|
||||
<CollapsibleSection title="Style">
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setStyleValue('backgroundColor', v)} />
|
||||
</div>
|
||||
{/* Text color in style */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Color</SectionLabel>
|
||||
<ColorSwatchGrid colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v: string) => setStyleValue('color', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setStyleValue('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setStyleValue('textAlign', a)} title={a}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setStyleValue('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
269
craft/src/panels/right/styles/HeroStylePanel.tsx
Normal file
269
craft/src/panels/right/styles/HeroStylePanel.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
StylePanelProps,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
uploadToWhp,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- Asset Browser Inline ---------- */
|
||||
const AssetBrowser: React.FC<{
|
||||
filter: 'image' | 'video' | 'all';
|
||||
onSelect: (url: string) => void;
|
||||
}> = ({ filter, onSelect }) => {
|
||||
const [assets, setAssets] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const filtered = filter === 'all' ? data.assets : data.assets.filter((a: any) => {
|
||||
const t = (a.type || '').toLowerCase();
|
||||
return filter === 'image' ? t.startsWith('image') : t.startsWith('video');
|
||||
});
|
||||
setAssets(filtered);
|
||||
}
|
||||
} catch (e) { console.error('Load assets failed:', e); }
|
||||
setLoading(false);
|
||||
}, [filter]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!open) loadAssets();
|
||||
setOpen(!open);
|
||||
}, [open, loadAssets]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleToggle} style={{
|
||||
...btnActiveStyle(open), width: '100%', marginTop: 4,
|
||||
}}>
|
||||
<i className={`fa ${loading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} />
|
||||
{open ? 'Close' : 'Browse Assets'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ maxHeight: 160, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{assets.map((asset, i) => (
|
||||
<div key={i} onClick={() => { onSelect(asset.url); setOpen(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s', background: '#27272a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}>
|
||||
{(asset.type || '').startsWith('image') ? (
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 4 }}>
|
||||
<i className="fa fa-film" style={{ fontSize: 20, color: '#71717a' }} />
|
||||
<div style={{ fontSize: 8, color: '#71717a', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 70 }}>
|
||||
{asset.name?.replace(/^\d+_[a-f0-9]+_/, '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<p style={{ gridColumn: '1/-1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: 12, margin: 0 }}>
|
||||
No {filter} assets uploaded yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- HERO STYLE PANEL ---------- */
|
||||
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleUpload = useCallback(async (file: File, propKey: string) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp(propKey, url);
|
||||
}, [setProp]);
|
||||
|
||||
const bgType = nodeProps.bgType || 'color';
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollapsibleSection title="Content">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input type="text" value={nodeProps.heading || ''} onChange={(e) => setProp('heading', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Subtitle</label>
|
||||
<textarea value={nodeProps.subtitle || ''} onChange={(e) => setProp('subtitle', e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Button URL</label>
|
||||
<input type="text" value={nodeProps.buttonHref || ''} onChange={(e) => setProp('buttonHref', e.target.value)} placeholder="#" style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Secondary Button</label>
|
||||
<input type="text" value={nodeProps.secondaryButtonText || ''} onChange={(e) => setProp('secondaryButtonText', e.target.value)} placeholder="Leave blank to hide" style={inputStyle} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Background">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
|
||||
<button key={t} onClick={() => setProp('bgType', t)} style={btnActiveStyle(bgType === t)}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bgType === 'color' && (
|
||||
<ColorPickerField label="Color" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
|
||||
)}
|
||||
|
||||
{bgType === 'gradient' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="From" value={nodeProps.bgGradientFrom || '#667eea'} onChange={(v) => setProp('bgGradientFrom', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="To" value={nodeProps.bgGradientTo || '#764ba2'} onChange={(v) => setProp('bgGradientTo', v)} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Angle: {nodeProps.bgGradientAngle || 135}°</label>
|
||||
<input type="range" min={0} max={360} value={nodeProps.bgGradientAngle || 135} onChange={(e) => setProp('bgGradientAngle', parseInt(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
{/* Gradient preview */}
|
||||
<div style={{ height: 24, borderRadius: 4, background: `linear-gradient(${nodeProps.bgGradientAngle || 135}deg, ${nodeProps.bgGradientFrom || '#667eea'}, ${nodeProps.bgGradientTo || '#764ba2'})`, border: '1px solid #3f3f46' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{bgType === 'image' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Image</label>
|
||||
{nodeProps.bgImage && (
|
||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
</div>
|
||||
<AssetBrowser filter="image" onSelect={(url) => setProp('bgImage', url)} />
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f, 'bgImage'); e.target.value = ''; }} />
|
||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste URL..."
|
||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bgType === 'video' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Video</label>
|
||||
{nodeProps.bgVideo && (
|
||||
<div style={{ marginBottom: 6, padding: 8, background: '#18181b', borderRadius: 4, border: '1px solid #3f3f46', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<i className="fa fa-film" style={{ color: '#3b82f6' }} />
|
||||
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{nodeProps.bgVideo.replace(/.*filename=/, '').replace(/^\d+_[a-f0-9]+_/, '') || nodeProps.bgVideo.split('/').pop()}
|
||||
</span>
|
||||
<button onClick={() => setProp('bgVideo', '')} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'video/*';
|
||||
input.onchange = () => { const f = input.files?.[0]; if (f) handleUpload(f, 'bgVideo'); };
|
||||
input.click();
|
||||
}} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload Video
|
||||
</button>
|
||||
</div>
|
||||
<AssetBrowser filter="video" onSelect={(url) => setProp('bgVideo', url)} />
|
||||
<input type="text" value={nodeProps.bgVideo || ''} placeholder="YouTube, Vimeo, or .mp4 URL"
|
||||
onChange={(e) => setProp('bgVideo', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Overlay ({nodeProps.overlayOpacity || 0}%)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={nodeProps.overlayColor || '#000000'} onChange={(e) => setProp('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Colors">
|
||||
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#ffffff'} onChange={(v) => setProp('textColor', v)} />
|
||||
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="Button BG" value={nodeProps.buttonBgColor || '#3b82f6'} onChange={(v) => setProp('buttonBgColor', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ColorPickerField label="Button Text" value={nodeProps.buttonTextColor || '#ffffff'} onChange={(v) => setProp('buttonTextColor', v)} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Layout">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Minimum Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
|
||||
<button key={h} onClick={() => setProp('minHeight', h)} style={btnActiveStyle(nodeProps.minHeight === h)}>
|
||||
{h === '100vh' ? 'Full' : h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Vertical</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['top', 'center', 'bottom'] as const).map((v) => (
|
||||
<button key={v} onClick={() => setProp('verticalAlign', v)} style={btnActiveStyle(nodeProps.verticalAlign === v)}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Text Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp('textAlign', a)} style={btnActiveStyle(nodeProps.textAlign === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
215
craft/src/panels/right/styles/ImageStylePanel.tsx
Normal file
215
craft/src/panels/right/styles/ImageStylePanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
uploadToWhp,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
||||
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
const [imgUrl, setImgUrl] = useState(nodeProps.src || '');
|
||||
|
||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg";
|
||||
const isPlaceholder = !nodeProps.src || nodeProps.src.startsWith('data:image/svg');
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = url; });
|
||||
setImgUrl(url);
|
||||
}
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
const maxWidthPresets = [
|
||||
{ label: '25%', value: '25%' },
|
||||
{ label: '50%', value: '50%' },
|
||||
{ label: '75%', value: '75%' },
|
||||
{ label: '100%', value: '100%' },
|
||||
];
|
||||
|
||||
const getFriendlyName = (src: string) => {
|
||||
const match = src.match(/filename=([^&]+)/);
|
||||
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
|
||||
return src.split('/').pop() || 'image';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Image source with upload/browse */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Image Source</SectionLabel>
|
||||
|
||||
{!isPlaceholder && nodeProps.src ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={nodeProps.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
||||
<button
|
||||
onClick={() => { actions.setProp(selectedId, (props: any) => { props.src = ''; }); setImgUrl(''); }}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(nodeProps.src || '')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleUpload(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||
Drop image here or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload + Browse buttons */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline asset browser grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = asset.url; });
|
||||
setImgUrl(asset.url);
|
||||
setShowBrowser(false);
|
||||
}}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input for advanced users */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div className="guided-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={imgUrl}
|
||||
placeholder="Or paste image URL..."
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<button
|
||||
className="preset-btn apply-btn"
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => { props.src = imgUrl; });
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt Text */}
|
||||
<TextInputField
|
||||
label="Alt Text"
|
||||
value={nodeProps.alt || ''}
|
||||
placeholder="Describe the image..."
|
||||
onChange={(v) => {
|
||||
actions.setProp(selectedId, (props: any) => { props.alt = v; });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Border Radius */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Width */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Max Width</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={maxWidthPresets}
|
||||
activeValue={style.maxWidth as string}
|
||||
onSelect={(v) => setPropStyle('maxWidth', v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
197
craft/src/panels/right/styles/MediaStylePanel.tsx
Normal file
197
craft/src/panels/right/styles/MediaStylePanel.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
|
||||
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Video URL */}
|
||||
{nodeProps.videoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Video URL</label>
|
||||
<input type="text" value={nodeProps.videoUrl || ''} onChange={(e) => setProp('videoUrl', e.target.value)} placeholder="YouTube, Vimeo, or .mp4 URL" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map URL */}
|
||||
{nodeProps.embedUrl !== undefined && nodeProps.videoUrl === undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Embed URL</label>
|
||||
<input type="text" value={nodeProps.embedUrl || ''} onChange={(e) => setProp('embedUrl', e.target.value)} placeholder="Google Maps embed URL" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map address */}
|
||||
{nodeProps.address !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Address</label>
|
||||
<input type="text" value={nodeProps.address || ''} onChange={(e) => setProp('address', e.target.value)} placeholder="123 Main St..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video options */}
|
||||
{nodeProps.autoplay !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.autoplay || false} onChange={(e) => setProp('autoplay', e.target.checked)} />
|
||||
Autoplay
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.loop !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.loop || false} onChange={(e) => setProp('loop', e.target.checked)} />
|
||||
Loop
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.controls !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.controls !== false} onChange={(e) => setProp('controls', e.target.checked)} />
|
||||
Show Controls
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery items */}
|
||||
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
|
||||
<CollapsibleSection title="Images">
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey="images"
|
||||
items={nodeProps.images}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{item.src !== undefined && (
|
||||
<input type="text" value={item.src || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.images || [])];
|
||||
updated[index] = { ...updated[index], src: e.target.value };
|
||||
props.images = updated;
|
||||
});
|
||||
}} placeholder="Image URL" style={smallInputStyle} />
|
||||
)}
|
||||
{item.caption !== undefined && (
|
||||
<input type="text" value={item.caption || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.images || [])];
|
||||
updated[index] = { ...updated[index], caption: e.target.value };
|
||||
props.images = updated;
|
||||
});
|
||||
}} placeholder="Caption" style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ src: '', caption: '' }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Slides */}
|
||||
{nodeProps.slides !== undefined && Array.isArray(nodeProps.slides) && (
|
||||
<CollapsibleSection title="Slides">
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey="slides"
|
||||
items={nodeProps.slides}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{item.heading !== undefined && (
|
||||
<input type="text" value={item.heading || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], heading: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Heading" style={smallInputStyle} />
|
||||
)}
|
||||
{item.text !== undefined && (
|
||||
<textarea value={item.text || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], text: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
)}
|
||||
{item.image !== undefined && (
|
||||
<input type="text" value={item.image || ''} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], image: e.target.value };
|
||||
props.slides = updated;
|
||||
});
|
||||
}} placeholder="Image URL" style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ heading: 'New Slide', text: '', image: '' }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{nodeProps.overlayColor !== undefined && (
|
||||
<CollapsibleSection title="Overlay" defaultOpen={false}>
|
||||
<ColorPickerField label="Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
|
||||
{nodeProps.overlayOpacity !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {nodeProps.overlayOpacity ?? 0}%</label>
|
||||
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity ?? 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
192
craft/src/panels/right/styles/NavStylePanel.tsx
Normal file
192
craft/src/panels/right/styles/NavStylePanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- NAV / MENU / LOGO ---------- */
|
||||
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const links: any[] = nodeProps.links || [];
|
||||
|
||||
const updateLink = useCallback((index: number, field: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.links = [...(props.links || []), { text: 'New Link', href: '#' }];
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removeLink = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated.splice(index, 1);
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
/* Detect standalone Logo vs Navbar/Menu */
|
||||
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Standalone Logo component settings */}
|
||||
{isStandaloneLogo && (
|
||||
<CollapsibleSection title="Logo" defaultOpen={true}>
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp('type', 'text')}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'text' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'text' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'text' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-font" style={{ marginRight: 4 }} />Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp('type', 'image')}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'image' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'image' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'image' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-image" style={{ marginRight: 4 }} />Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{nodeProps.type === 'text' && (
|
||||
<>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Font Size</label>
|
||||
<input type="text" value={nodeProps.fontSize || '20px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Font Weight</label>
|
||||
<select value={nodeProps.fontWeight || '700'} onChange={(e) => setProp('fontWeight', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
<option value="300">Light</option>
|
||||
<option value="400">Normal</option>
|
||||
<option value="500">Medium</option>
|
||||
<option value="600">Semi</option>
|
||||
<option value="700">Bold</option>
|
||||
<option value="800">Extra Bold</option>
|
||||
</select>
|
||||
</div>
|
||||
<ColorPickerField label="Text Color" value={nodeProps.color || '#1f2937'} onChange={(v) => setProp('color', v)} />
|
||||
</>
|
||||
)}
|
||||
{nodeProps.type === 'image' && (
|
||||
<>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Image URL</label>
|
||||
<input type="text" value={nodeProps.imageSrc || ''} onChange={(e) => setProp('imageSrc', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Image Width</label>
|
||||
<input type="text" value={nodeProps.imageWidth || '120px'} onChange={(e) => setProp('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input type="text" value={nodeProps.href || '/'} onChange={(e) => setProp('href', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Navbar Logo settings */}
|
||||
{nodeProps.logoText !== undefined && (
|
||||
<CollapsibleSection title="Logo">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input type="text" value={nodeProps.logoText || ''} onChange={(e) => setProp('logoText', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{nodeProps.logoImage !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Image URL</label>
|
||||
<input type="text" value={nodeProps.logoImage || ''} onChange={(e) => setProp('logoImage', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.logoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Link URL</label>
|
||||
<input type="text" value={nodeProps.logoUrl || ''} onChange={(e) => setProp('logoUrl', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Links (not shown for standalone Logo) */}
|
||||
{!isStandaloneLogo && (
|
||||
<CollapsibleSection title="Links">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={link.text || ''} onChange={(e) => updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
|
||||
<button onClick={() => removeLink(i)} style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={link.href || ''} onChange={(e) => updateLink(i, 'href', e.target.value)} placeholder="URL" style={{ ...smallInputStyle, color: '#71717a' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addLink} style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}>
|
||||
+ Add Link
|
||||
</button>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Colors (not shown for standalone Logo - it has its own color picker) */}
|
||||
{!isStandaloneLogo && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{nodeProps.backgroundColor !== undefined && (
|
||||
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} />
|
||||
)}
|
||||
{nodeProps.textColor !== undefined && (
|
||||
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
|
||||
)}
|
||||
{nodeProps.ctaColor !== undefined && (
|
||||
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Style overrides */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={(nodeProps.style || {}).padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
210
craft/src/panels/right/styles/PricingStylePanel.tsx
Normal file
210
craft/src/panels/right/styles/PricingStylePanel.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
StylePanelProps,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
const bulletOptions = [
|
||||
{ label: '✓', value: 'check' },
|
||||
{ label: '●', value: 'dot' },
|
||||
{ label: '→', value: 'arrow' },
|
||||
{ label: '★', value: 'star' },
|
||||
{ label: '—', value: 'dash' },
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
const bulletChar: Record<string, string> = {
|
||||
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
|
||||
};
|
||||
|
||||
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const [expandedPlan, setExpandedPlan] = useState<number>(0);
|
||||
|
||||
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
|
||||
const currentBullet = nodeProps.bulletType || 'check';
|
||||
|
||||
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated[planIndex] = { ...updated[planIndex], [field]: value };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addPlan = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated.push({
|
||||
name: 'New Plan',
|
||||
price: '$0',
|
||||
period: '/month',
|
||||
features: ['Feature 1'],
|
||||
buttonText: 'Choose Plan',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
});
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removePlan = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
updated.splice(index, 1);
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addFeature = useCallback((planIndex: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features.push('New feature');
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const updateFeature = useCallback((planIndex: number, featureIndex: number, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features[featureIndex] = value;
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removeFeature = useCallback((planIndex: number, featureIndex: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||||
features.splice(featureIndex, 1);
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
props.plans = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bullet type */}
|
||||
<CollapsibleSection title="Bullet Style">
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bulletOptions.map((b) => (
|
||||
<button key={b.value} onClick={() => actions.setProp(selectedId, (p: any) => { p.bulletType = b.value; })}
|
||||
style={{ ...btnActiveStyle(currentBullet === b.value), flex: 1, fontSize: 14 }}>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Plans */}
|
||||
<CollapsibleSection title={`Plans (${plans.length})`}>
|
||||
{plans.map((plan, i) => {
|
||||
const isExpanded = expandedPlan === i;
|
||||
const features: string[] = Array.isArray(plan.features) ? plan.features : [];
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
marginBottom: 8, background: '#18181b', borderRadius: 6,
|
||||
border: plan.isFeatured ? '1px solid #3b82f6' : '1px solid #27272a',
|
||||
}}>
|
||||
{/* Plan header - click to expand */}
|
||||
<div onClick={() => setExpandedPlan(isExpanded ? -1 : i)} style={{
|
||||
padding: '8px 10px', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7' }}>
|
||||
{plan.name || 'Plan'} {plan.isFeatured && <span style={{ fontSize: 9, background: '#3b82f6', color: '#fff', padding: '1px 5px', borderRadius: 3, marginLeft: 4 }}>Featured</span>}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#71717a' }}>{plan.price}</span>
|
||||
<i className={`fa fa-chevron-${isExpanded ? 'up' : 'down'}`} style={{ fontSize: 10, color: '#71717a' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded plan settings */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '0 10px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Name</label>
|
||||
<input type="text" value={plan.name || ''} onChange={(e) => updatePlan(i, 'name', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Price</label>
|
||||
<input type="text" value={plan.price || ''} onChange={(e) => updatePlan(i, 'price', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Period</label>
|
||||
<input type="text" value={plan.period || ''} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Button Text</label>
|
||||
<input type="text" value={plan.buttonText || ''} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b' }}>Button URL</label>
|
||||
<input type="text" value={plan.buttonHref || ''} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} style={smallInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!plan.isFeatured} onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)} />
|
||||
Featured (highlighted)
|
||||
</label>
|
||||
|
||||
{/* Features list */}
|
||||
<div>
|
||||
<label style={{ fontSize: 9, color: '#52525b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Features ({features.length})</span>
|
||||
<button onClick={() => addFeature(i)} style={{ fontSize: 9, background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 3, padding: '2px 6px', cursor: 'pointer' }}>
|
||||
+ Add
|
||||
</button>
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4 }}>
|
||||
{features.map((feat, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#10b981', width: 14, textAlign: 'center' }}>{bulletChar[currentBullet] || '✓'}</span>
|
||||
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...smallInputStyle, flex: 1 }} />
|
||||
<button onClick={() => removeFeature(i, fi)} style={{ fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, padding: '1px 4px', cursor: 'pointer', lineHeight: 1 }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove plan */}
|
||||
{plans.length > 1 && (
|
||||
<button onClick={() => removePlan(i)} style={{ fontSize: 10, background: 'none', color: '#ef4444', border: '1px solid #ef4444', borderRadius: 4, padding: '3px 8px', cursor: 'pointer', marginTop: 4 }}>
|
||||
Remove Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button onClick={addPlan} style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', marginTop: 4 }}>
|
||||
+ Add Plan
|
||||
</button>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Colors */}
|
||||
<CollapsibleSection title="Colors" defaultOpen={false}>
|
||||
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
239
craft/src/panels/right/styles/SectionTypePanel.tsx
Normal file
239
craft/src/panels/right/styles/SectionTypePanel.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
||||
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
// Find all string/number/boolean props
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
const scalarProps = Object.entries(nodeProps).filter(
|
||||
([key, val]) => !SKIP_PROPS.has(key) && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean')
|
||||
);
|
||||
const colorProps = scalarProps.filter(([key]) => /color/i.test(key));
|
||||
const otherScalarProps = scalarProps.filter(([key]) => !/color/i.test(key));
|
||||
const arrayProps = Object.entries(nodeProps).filter(([key, val]) => !SKIP_PROPS.has(key) && Array.isArray(val));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Content props */}
|
||||
{otherScalarProps.length > 0 && (
|
||||
<CollapsibleSection title="Content">
|
||||
{otherScalarProps.map(([key, val]) => {
|
||||
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
|
||||
if (typeof val === 'boolean') {
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={val} onChange={(e) => setProp(key, e.target.checked)} />
|
||||
{humanLabel}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof val === 'number') {
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
<input type="number" value={val} onChange={(e) => setProp(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// String - use textarea for long values
|
||||
const isLong = String(val).length > 60;
|
||||
return (
|
||||
<div key={key} style={sectionGap}>
|
||||
<label style={labelStyle}>{humanLabel}</label>
|
||||
{isLong ? (
|
||||
<textarea value={String(val)} onChange={(e) => setProp(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={String(val)} onChange={(e) => setProp(key, e.target.value)} style={inputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Color props */}
|
||||
{colorProps.length > 0 && (
|
||||
<CollapsibleSection title="Colors">
|
||||
{colorProps.map(([key, val]) => (
|
||||
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setProp(key, v)} />
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Array props (features, items, plans, testimonials, etc.) */}
|
||||
{arrayProps.map(([key, items]) => {
|
||||
const arrayItems = items as any[];
|
||||
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
|
||||
const sampleItem = arrayItems[0] || {};
|
||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||
|
||||
return (
|
||||
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey={key}
|
||||
items={arrayItems}
|
||||
renderItem={(item: any, index: number) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(item)}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = e.target.value;
|
||||
props[key] = updated;
|
||||
});
|
||||
}}
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{itemFields.map((field) => {
|
||||
const fieldVal = item[field];
|
||||
if (typeof fieldVal === 'boolean') {
|
||||
return (
|
||||
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.checked };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} />
|
||||
{field}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (typeof fieldVal === 'number') {
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
<input type="number" value={fieldVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// color fields
|
||||
if (/color/i.test(field) && typeof fieldVal === 'string') {
|
||||
return (
|
||||
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
|
||||
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// long text
|
||||
const strVal = String(fieldVal ?? '');
|
||||
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
|
||||
return (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||
{isLongField ? (
|
||||
<textarea value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
) : (
|
||||
<input type="text" value={strVal} onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[key] || [])];
|
||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||
props[key] = updated;
|
||||
});
|
||||
}} style={smallInputStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
|
||||
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Style */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setPropStyle('textAlign', a)} title={a}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
177
craft/src/panels/right/styles/SocialStylePanel.tsx
Normal file
177
craft/src/panels/right/styles/SocialStylePanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
|
||||
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const setProp = useCallback((key: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [key]: value };
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Social Links list */}
|
||||
{nodeProps.links !== undefined && Array.isArray(nodeProps.links) && (
|
||||
<CollapsibleSection title="Links">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(nodeProps.links || []).map((link: any, i: number) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa', width: 60, textTransform: 'capitalize' }}>{link.platform || 'link'}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={link.url || ''}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated[i] = { ...updated[i], url: e.target.value };
|
||||
props.links = updated;
|
||||
});
|
||||
}}
|
||||
placeholder="URL"
|
||||
style={{ ...smallInputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated.splice(i, 1);
|
||||
props.links = updated;
|
||||
});
|
||||
}}
|
||||
style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) return;
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.links = [...(props.links || []), { platform: e.target.value, url: '#' }];
|
||||
});
|
||||
e.target.value = '';
|
||||
}}
|
||||
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">+ Add Platform...</option>
|
||||
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp'].map((p) => (
|
||||
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Icon name */}
|
||||
{nodeProps.iconName !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon Name</label>
|
||||
<input type="text" value={nodeProps.iconName || ''} onChange={(e) => setProp('iconName', e.target.value)} placeholder="fa-star" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.icon !== undefined && typeof nodeProps.icon === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon</label>
|
||||
<input type="text" value={nodeProps.icon || ''} onChange={(e) => setProp('icon', e.target.value)} placeholder="fa-star or emoji" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star rating */}
|
||||
{nodeProps.rating !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Rating: {nodeProps.rating || 0}</label>
|
||||
<input type="range" min={0} max={5} step={0.5} value={nodeProps.rating || 0} onChange={(e) => setProp('rating', parseFloat(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.maxStars !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Max Stars</label>
|
||||
<input type="number" min={1} max={10} value={nodeProps.maxStars || 5} onChange={(e) => setProp('maxStars', parseInt(e.target.value) || 5)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
{nodeProps.iconColor !== undefined && (
|
||||
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
|
||||
)}
|
||||
{nodeProps.iconBgColor !== undefined && (
|
||||
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setProp('iconBgColor', v)} />
|
||||
)}
|
||||
{nodeProps.starColor !== undefined && (
|
||||
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setProp('starColor', v)} />
|
||||
)}
|
||||
{nodeProps.color !== undefined && typeof nodeProps.color === 'string' && (
|
||||
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setProp('color', v)} />
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
{nodeProps.iconSize !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon Size</label>
|
||||
<input type="text" value={nodeProps.iconSize || '24px'} onChange={(e) => setProp('iconSize', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.size !== undefined && typeof nodeProps.size === 'string' && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<input type="text" value={nodeProps.size || '24px'} onChange={(e) => setProp('size', e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alignment */}
|
||||
{nodeProps.alignment !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle(nodeProps.alignment === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
81
craft/src/panels/right/styles/TextStylePanel.tsx
Normal file
81
craft/src/panels/right/styles/TextStylePanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
FONT_FAMILIES,
|
||||
TEXT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
} from './shared';
|
||||
|
||||
/* ---------- TEXT ---------- */
|
||||
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const setPropStyle = useCallback(
|
||||
(property: string, value: string) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.style = { ...props.style, [property]: value };
|
||||
});
|
||||
},
|
||||
[actions, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Color</SectionLabel>
|
||||
<ColorSwatchGrid
|
||||
colors={TEXT_COLORS}
|
||||
activeValue={style.color as string}
|
||||
onSelect={(v) => setPropStyle('color', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Font Family</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={FONT_FAMILIES}
|
||||
activeValue={style.fontFamily as string}
|
||||
onSelect={(v) => setPropStyle('fontFamily', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Size</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={TEXT_SIZES}
|
||||
activeValue={style.fontSize as string}
|
||||
onSelect={(v) => setPropStyle('fontSize', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Font Weight</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={FONT_WEIGHTS}
|
||||
activeValue={String(style.fontWeight || '')}
|
||||
onSelect={(v) => setPropStyle('fontWeight', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
|
||||
onClick={() => setPropStyle('textAlign', a)}
|
||||
title={a}
|
||||
>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
13
craft/src/panels/right/styles/index.ts
Normal file
13
craft/src/panels/right/styles/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { TextStylePanel } from './TextStylePanel';
|
||||
export { ButtonStylePanel } from './ButtonStylePanel';
|
||||
export { ImageStylePanel } from './ImageStylePanel';
|
||||
export { ContainerStylePanel } from './ContainerStylePanel';
|
||||
export { HeroStylePanel } from './HeroStylePanel';
|
||||
export { NavStylePanel } from './NavStylePanel';
|
||||
export { MediaStylePanel } from './MediaStylePanel';
|
||||
export { FormStylePanel } from './FormStylePanel';
|
||||
export { SocialStylePanel } from './SocialStylePanel';
|
||||
export { SectionTypePanel } from './SectionTypePanel';
|
||||
export { PricingStylePanel } from './PricingStylePanel';
|
||||
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
|
||||
export { GenericPropsEditor } from './GenericPropsEditor';
|
||||
322
craft/src/panels/right/styles/shared.tsx
Normal file
322
craft/src/panels/right/styles/shared.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
GRADIENTS,
|
||||
} from '../../../constants/presets';
|
||||
|
||||
/* ---------- Helper: auto text color for bg ---------- */
|
||||
export function autoTextColor(bg: string): string {
|
||||
if (bg.startsWith('#')) {
|
||||
const hex = bg.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#18181b' : '#ffffff';
|
||||
}
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
/* ---------- Helper: upload to WHP ---------- */
|
||||
export async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/* ---------- Shared inline styles ---------- */
|
||||
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
|
||||
export const inputStyle: CSSProperties = { width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' };
|
||||
export const smallInputStyle: CSSProperties = { ...inputStyle, fontSize: 11, padding: '3px 6px' };
|
||||
export const btnActiveStyle = (active: boolean): CSSProperties => ({
|
||||
flex: 1, padding: '5px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
export const sectionGap: CSSProperties = { marginBottom: 14 };
|
||||
|
||||
/* ---------- Reusable sub-components ---------- */
|
||||
|
||||
interface SectionLabelProps { children: React.ReactNode; }
|
||||
export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
|
||||
<label className="guided-section-label">{children}</label>
|
||||
);
|
||||
|
||||
interface ColorSwatchGridProps {
|
||||
colors: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect }) => (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={activeValue || '#000000'}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={activeValue || ''}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
placeholder="#000000"
|
||||
style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }}
|
||||
/>
|
||||
</div>
|
||||
<div className="preset-grid">
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
className={`preset-swatch ${activeValue === c.value ? 'active' : ''}`}
|
||||
style={{ background: c.value }}
|
||||
onClick={() => onSelect(c.value)}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PresetButtonGridProps {
|
||||
presets: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
|
||||
<div className="preset-grid">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
|
||||
onClick={() => onSelect(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface GradientSwatchGridProps {
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
|
||||
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
|
||||
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
|
||||
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8})/);
|
||||
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
|
||||
return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
}
|
||||
|
||||
export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeValue, onSelect }) => {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const parsed = parseGradient(activeValue);
|
||||
const [customFrom, setCustomFrom] = useState(parsed.from);
|
||||
const [customTo, setCustomTo] = useState(parsed.to);
|
||||
const [customAngle, setCustomAngle] = useState(parsed.angle);
|
||||
|
||||
const applyCustomGradient = (from: string, to: string, angle: number) => {
|
||||
setCustomFrom(from);
|
||||
setCustomTo(to);
|
||||
setCustomAngle(angle);
|
||||
onSelect(`linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Custom gradient builder toggle */}
|
||||
<button
|
||||
onClick={() => setShowCustom(!showCustom)}
|
||||
style={{
|
||||
width: '100%', padding: '5px 8px', fontSize: 11, marginBottom: 6,
|
||||
background: showCustom ? '#3b82f6' : '#27272a', color: showCustom ? '#fff' : '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<i className={`fa fa-${showCustom ? 'chevron-down' : 'sliders'}`} style={{ fontSize: 10 }} />
|
||||
Custom Gradient
|
||||
</button>
|
||||
{showCustom && (
|
||||
<div style={{ padding: 8, background: '#1e1e22', borderRadius: 6, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>From</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||||
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
<input type="text" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||||
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>To</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||||
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||
<input type="text" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||||
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>Angle: {customAngle}°</label>
|
||||
<input type="range" min={0} max={360} value={customAngle} onChange={(e) => applyCustomGradient(customFrom, customTo, parseInt(e.target.value))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
{/* Live preview */}
|
||||
<div style={{ height: 20, borderRadius: 4, marginTop: 6, background: `linear-gradient(${customAngle}deg, ${customFrom}, ${customTo})`, border: '1px solid #3f3f46' }} />
|
||||
</div>
|
||||
)}
|
||||
{/* Preset swatches */}
|
||||
<div className="preset-grid gradient-grid">
|
||||
{GRADIENTS.map((g) => (
|
||||
<button
|
||||
key={g.label}
|
||||
className={`preset-swatch gradient-swatch ${activeValue === g.value ? 'active' : ''}`}
|
||||
style={{ background: g.value === 'none' ? '#27272a' : g.value }}
|
||||
onClick={() => onSelect(g.value)}
|
||||
title={g.label}
|
||||
>
|
||||
{g.value === 'none' ? '\u00D7' : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextInputFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const TextInputField: React.FC<TextInputFieldProps> = ({ label, value, placeholder, onChange }) => (
|
||||
<div className="guided-section">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Color picker with hex input ---------- */
|
||||
interface ColorPickerFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange }) => (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value || '#000000'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Collapsible section ---------- */
|
||||
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #2d2d3a', paddingTop: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', background: 'none', border: 'none', color: '#a1a1aa', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
<i className={`fa fa-chevron-${open ? 'down' : 'right'}`} style={{ fontSize: 8, width: 10 }} />
|
||||
{title}
|
||||
</button>
|
||||
{open && <div style={{ paddingTop: 8 }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- StylePanelProps interface ---------- */
|
||||
export interface StylePanelProps {
|
||||
selectedId: string;
|
||||
nodeProps: Record<string, any>;
|
||||
}
|
||||
|
||||
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
|
||||
interface ArrayPropEditorProps {
|
||||
selectedId: string;
|
||||
propKey: string;
|
||||
items: any[];
|
||||
renderItem: (item: any, index: number) => React.ReactNode;
|
||||
emptyItem: any;
|
||||
}
|
||||
export const ArrayPropEditor: React.FC<ArrayPropEditorProps> = ({ selectedId, propKey, items, renderItem, emptyItem }) => {
|
||||
const { actions } = useEditor();
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props[propKey] = [...(props[propKey] || []), typeof emptyItem === 'object' ? { ...emptyItem } : emptyItem];
|
||||
});
|
||||
}, [actions, selectedId, propKey, emptyItem]);
|
||||
|
||||
const removeItem = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props[propKey] || [])];
|
||||
updated.splice(index, 1);
|
||||
props[propKey] = updated;
|
||||
});
|
||||
}, [actions, selectedId, propKey]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
style={{ position: 'absolute', top: 4, right: 4, padding: '1px 5px', fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', zIndex: 1 }}
|
||||
title="Remove"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
{renderItem(item, i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
159
craft/src/panels/topbar/HeadCodeModal.tsx
Normal file
159
craft/src/panels/topbar/HeadCodeModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
|
||||
interface HeadCodeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div style={backdropStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={modalHeaderStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<i className="fa fa-code" style={{ color: 'var(--color-accent)', fontSize: 16 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Custom Head Code
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
Add tracking scripts, meta tags, or custom CSS to your site's <head> section.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeButtonStyle}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 20, flex: 1, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(59,130,246,0.08)',
|
||||
border: '1px solid rgba(59,130,246,0.2)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
<i className="fa fa-info-circle" style={{ color: 'var(--color-accent)', marginRight: 6 }} />
|
||||
Code added here will be injected into the <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 4px', borderRadius: 3, fontSize: 11 }}><head></code> of every page on your site. Use it for analytics, custom fonts, or global CSS.
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={design.headCode || ''}
|
||||
onChange={(e) => updateDesign({ headCode: e.target.value })}
|
||||
placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 300,
|
||||
padding: 14,
|
||||
background: '#0d0d0f',
|
||||
color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 8,
|
||||
fontFamily: 'Source Code Pro, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
tabSize: 2,
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
}}>
|
||||
<button onClick={onClose} style={doneButtonStyle}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Styles ---------- */
|
||||
|
||||
const backdropStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: '90vw',
|
||||
maxWidth: 700,
|
||||
maxHeight: '80vh',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
};
|
||||
|
||||
const modalHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const doneButtonStyle: React.CSSProperties = {
|
||||
padding: '8px 24px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: 'var(--color-accent)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
607
craft/src/panels/topbar/TemplateModal.tsx
Normal file
607
craft/src/panels/topbar/TemplateModal.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import {
|
||||
allTemplates,
|
||||
TemplateDefinition,
|
||||
TemplateComponent,
|
||||
TemplateCategory,
|
||||
} from '../../templates';
|
||||
import { componentResolver } from '../../components/resolver';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TemplateModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type FilterTab = 'all' | TemplateCategory;
|
||||
|
||||
const TABS: { label: string; value: FilterTab }[] = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Business', value: 'business' },
|
||||
{ label: 'Creative', value: 'creative' },
|
||||
{ label: 'Personal', value: 'personal' },
|
||||
{ label: 'Community', value: 'community' },
|
||||
];
|
||||
|
||||
const CATEGORY_COLORS: Record<TemplateCategory, string> = {
|
||||
business: '#3b82f6',
|
||||
creative: '#a855f7',
|
||||
personal: '#f59e0b',
|
||||
community: '#10b981',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) => {
|
||||
const { actions, query } = useEditor();
|
||||
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
|
||||
const { updateDesign } = useSiteDesign();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>('all');
|
||||
const [confirmTemplate, setConfirmTemplate] = useState<TemplateDefinition | null>(null);
|
||||
const [applyDesign, setApplyDesign] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (confirmTemplate) setConfirmTemplate(null);
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, confirmTemplate, onClose]);
|
||||
|
||||
// Filter templates by category
|
||||
const filtered = useMemo(() => {
|
||||
if (activeTab === 'all') return allTemplates;
|
||||
return allTemplates.filter((t) => t.category === activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
// Resolve a TemplateComponent type name to its React component
|
||||
const resolverMap = componentResolver as Record<string, React.ComponentType<any>>;
|
||||
|
||||
/**
|
||||
* Add all components from a template definition onto the current (empty) canvas ROOT.
|
||||
* Uses Craft.js parseReactElement + addNodeTree which correctly builds valid node structures.
|
||||
*/
|
||||
const addTemplateComponents = useCallback(
|
||||
(components: TemplateComponent[]) => {
|
||||
for (const comp of components) {
|
||||
const Component = resolverMap[comp.type];
|
||||
if (!Component) {
|
||||
console.warn(`Template references unknown component type: ${comp.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const element = React.createElement(Component, comp.props);
|
||||
const tree = query.parseReactElement(element).toNodeTree();
|
||||
actions.addNodeTree(tree, 'ROOT');
|
||||
}
|
||||
},
|
||||
[query, actions, resolverMap],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear the current canvas by deleting all children of ROOT.
|
||||
*/
|
||||
const clearCanvas = useCallback(() => {
|
||||
try {
|
||||
const rootNode = query.node('ROOT').get();
|
||||
const childIds = [...(rootNode.data.nodes || [])];
|
||||
childIds.forEach((id) => {
|
||||
try {
|
||||
actions.delete(id);
|
||||
} catch {
|
||||
// Node may already be removed
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ROOT doesn't exist yet or is empty -- that's fine
|
||||
}
|
||||
}, [query, actions]);
|
||||
|
||||
/**
|
||||
* After all pages are loaded, apply header and footer template content.
|
||||
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
|
||||
*/
|
||||
const applyHeaderFooter = useCallback(
|
||||
(tpl: TemplateDefinition, firstPageId: string) => {
|
||||
const hasHeader = tpl.header?.components?.length > 0;
|
||||
const hasFooter = tpl.footer?.components?.length > 0;
|
||||
|
||||
if (!hasHeader && !hasFooter) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const applyZone = (
|
||||
switchFn: () => void,
|
||||
components: TemplateComponent[],
|
||||
next: () => void,
|
||||
) => {
|
||||
switchFn();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
clearCanvas();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
addTemplateComponents(components);
|
||||
} catch (e) {
|
||||
console.warn('Failed to add zone components:', e);
|
||||
}
|
||||
setTimeout(next, 30);
|
||||
}, 20);
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear zone:', e);
|
||||
setTimeout(next, 30);
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
// Switch back to the first page
|
||||
switchPage(firstPageId);
|
||||
setTimeout(() => setLoading(false), 30);
|
||||
};
|
||||
|
||||
const doFooter = () => {
|
||||
if (hasFooter) {
|
||||
applyZone(editFooter, tpl.footer.components, finish);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
if (hasHeader) {
|
||||
applyZone(editHeader, tpl.header.components, doFooter);
|
||||
} else {
|
||||
doFooter();
|
||||
}
|
||||
},
|
||||
[clearCanvas, addTemplateComponents, switchPage, editHeader, editFooter],
|
||||
);
|
||||
|
||||
// Load the selected template
|
||||
const handleLoad = useCallback(() => {
|
||||
if (!confirmTemplate) return;
|
||||
setLoading(true);
|
||||
|
||||
const tpl = confirmTemplate;
|
||||
|
||||
try {
|
||||
// 1. Optionally apply design tokens
|
||||
if (applyDesign && tpl.design) {
|
||||
updateDesign(tpl.design);
|
||||
}
|
||||
|
||||
// 2. Remove all pages except the first
|
||||
const currentPages = [...pages];
|
||||
const keepId = currentPages[0]?.id;
|
||||
for (let i = currentPages.length - 1; i >= 1; i--) {
|
||||
deletePage(currentPages[i].id);
|
||||
}
|
||||
|
||||
// 3. Clear the current canvas and add the first template page's components
|
||||
clearCanvas();
|
||||
|
||||
// Use a short delay to let the clear settle before adding new nodes
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const firstPage = tpl.pages[0];
|
||||
addTemplateComponents(firstPage.content.components);
|
||||
|
||||
// 4. Add remaining pages (if multi-page template)
|
||||
const finishPages = () => {
|
||||
// 5. Apply header and footer template content
|
||||
if (keepId) {
|
||||
applyHeaderFooter(tpl, keepId);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (tpl.pages.length > 1) {
|
||||
let pageIndex = 1;
|
||||
const addNextPage = () => {
|
||||
if (pageIndex >= tpl.pages.length) {
|
||||
// Switch back to first page, then apply header/footer
|
||||
if (keepId) switchPage(keepId);
|
||||
setTimeout(finishPages, 30);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageDef = tpl.pages[pageIndex];
|
||||
addPage(pageDef.name, pageDef.slug);
|
||||
|
||||
// After addPage, the new page is active with an empty canvas.
|
||||
// Give a tick for Craft.js to settle, then add components.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
addTemplateComponents(pageDef.content.components);
|
||||
} catch (e) {
|
||||
console.warn('Failed to add components for page', pageDef.name, e);
|
||||
}
|
||||
pageIndex++;
|
||||
setTimeout(addNextPage, 30);
|
||||
}, 30);
|
||||
};
|
||||
|
||||
setTimeout(addNextPage, 30);
|
||||
} else {
|
||||
finishPages();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load template:', e);
|
||||
setLoading(false);
|
||||
}
|
||||
}, 20);
|
||||
} catch (e) {
|
||||
console.error('Failed to load template:', e);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setConfirmTemplate(null);
|
||||
onClose();
|
||||
}, [confirmTemplate, applyDesign, query, actions, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
if (confirmTemplate) {
|
||||
setConfirmTemplate(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[confirmTemplate, onClose],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div style={backdropStyle} onClick={handleBackdropClick}>
|
||||
<div style={modalStyle}>
|
||||
{/* Header */}
|
||||
<div style={modalHeaderStyle}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#e4e4e7' }}>
|
||||
Templates
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#71717a' }}>
|
||||
Choose a template to get started quickly
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeButtonStyle} title="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div style={tabBarStyle}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
style={{
|
||||
...tabStyle,
|
||||
...(activeTab === tab.value ? tabActiveStyle : {}),
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template Grid */}
|
||||
<div style={gridContainerStyle}>
|
||||
<div style={gridStyle}>
|
||||
{filtered.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
onSelect={() => setConfirmTemplate(tpl)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: 40, color: '#71717a' }}>
|
||||
No templates in this category.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmTemplate && (
|
||||
<div style={confirmOverlayStyle} onClick={() => setConfirmTemplate(null)}>
|
||||
<div style={confirmDialogStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: '#e4e4e7' }}>
|
||||
Load "{confirmTemplate.name}"?
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: '#a1a1aa' }}>
|
||||
This will replace your current content with the template pages and components.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 13,
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
marginBottom: 24,
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyDesign}
|
||||
onChange={(e) => setApplyDesign(e.target.checked)}
|
||||
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer' }}
|
||||
/>
|
||||
Apply template colors and fonts to site design
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setConfirmTemplate(null)}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#a1a1aa',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#ffffff',
|
||||
background: '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load Template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TemplateCard sub-component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TemplateCard: React.FC<{
|
||||
template: TemplateDefinition;
|
||||
onSelect: () => void;
|
||||
}> = ({ template, onSelect }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${hovered ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
backgroundColor: hovered ? 'var(--color-bg-elevated)' : 'var(--color-bg-surface)',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
transform: hovered ? 'translateY(-2px)' : 'none',
|
||||
boxShadow: hovered ? '0 4px 12px rgba(0,0,0,0.3)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 120,
|
||||
backgroundColor: '#1a1a24',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={template.thumbnail}
|
||||
alt={template.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#e4e4e7' }}>
|
||||
{template.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: CATEGORY_COLORS[template.category],
|
||||
backgroundColor: `${CATEGORY_COLORS[template.category]}18`,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 11, color: '#71717a', lineHeight: 1.4 }}>
|
||||
{template.description}
|
||||
</p>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#52525b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-file-o" style={{ fontSize: 9 }} />
|
||||
{template.isMultiPage ? `${template.pages.length} pages` : 'Single page'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const backdropStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: '90vw',
|
||||
maxWidth: 900,
|
||||
maxHeight: '85vh',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
};
|
||||
|
||||
const modalHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 14,
|
||||
color: '#71717a',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const tabBarStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
padding: '12px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
overflowX: 'auto',
|
||||
};
|
||||
|
||||
const tabStyle: React.CSSProperties = {
|
||||
padding: '6px 16px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: '#71717a',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.15s ease',
|
||||
};
|
||||
|
||||
const tabActiveStyle: React.CSSProperties = {
|
||||
color: '#e4e4e7',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderColor: 'var(--color-border)',
|
||||
};
|
||||
|
||||
const gridContainerStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 20px 20px',
|
||||
};
|
||||
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const confirmOverlayStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const confirmDialogStyle: React.CSSProperties = {
|
||||
width: '90%',
|
||||
maxWidth: 420,
|
||||
padding: '24px',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||
};
|
||||
274
craft/src/panels/topbar/TopBar.tsx
Normal file
274
craft/src/panels/topbar/TopBar.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { useWhpApi } from '../../hooks/useWhpApi';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { DeviceMode } from '../../types';
|
||||
import { TemplateModal } from './TemplateModal';
|
||||
import { HeadCodeModal } from './HeadCodeModal';
|
||||
|
||||
interface TopBarProps {
|
||||
device: DeviceMode;
|
||||
onDeviceChange: (device: DeviceMode) => void;
|
||||
}
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
const { actions, query, canUndo, canRedo } = useEditor((_state, query) => ({
|
||||
canUndo: query.history.canUndo(),
|
||||
canRedo: query.history.canRedo(),
|
||||
}));
|
||||
const { save, publish, load } = useWhpApi();
|
||||
const { headerPage, footerPage } = usePages();
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// Load saved state on mount
|
||||
useEffect(() => {
|
||||
if (!isWHP || hasLoadedRef.current) return;
|
||||
hasLoadedRef.current = true;
|
||||
|
||||
load().catch((e) => {
|
||||
console.warn('Failed to load project from WHP API:', e);
|
||||
});
|
||||
}, [isWHP, load]);
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
useEffect(() => {
|
||||
if (!isWHP) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
save()
|
||||
.then((result) => {
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent fail for auto-save
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isWHP, save]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const result = await save();
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2500);
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
}, [save]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
setPublishStatus('publishing');
|
||||
try {
|
||||
const result = await publish();
|
||||
if (result?.success) {
|
||||
setPublishStatus('published');
|
||||
setIsDraft(false);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
} else {
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Publish failed:', e);
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
}
|
||||
}, [publish]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="topbar">
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn">
|
||||
<i className="fa fa-arrow-left" /> Back to Panel
|
||||
</a>
|
||||
)}
|
||||
<span className="topbar-title">Site Builder</span>
|
||||
{isWHP && (
|
||||
<span className="topbar-domain">{whpConfig!.siteDomain}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="topbar-center">
|
||||
<div className="device-switcher">
|
||||
{(['desktop', 'tablet', 'mobile'] as DeviceMode[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`device-btn ${device === d ? 'active' : ''}`}
|
||||
onClick={() => onDeviceChange(d)}
|
||||
title={d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
>
|
||||
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo">
|
||||
<i className="fa fa-undo" />
|
||||
</button>
|
||||
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo">
|
||||
<i className="fa fa-repeat" />
|
||||
</button>
|
||||
<span className="topbar-divider" />
|
||||
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<i className="fa fa-th-large" /> Templates
|
||||
</button>
|
||||
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
|
||||
<i className="fa fa-code" /> Code
|
||||
</button>
|
||||
<button className="topbar-btn" title="Preview" onClick={() => {
|
||||
try {
|
||||
const serialized = query.serialize();
|
||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
||||
// Get header HTML
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerPage.craftState) {
|
||||
headerHtml = exportBodyHtml(headerPage.craftState).html;
|
||||
}
|
||||
} catch (e) { console.warn('Header export failed:', e); }
|
||||
|
||||
// Get page body HTML
|
||||
const bodyResult = exportBodyHtml(serialized);
|
||||
const bodyHtml = bodyResult.html;
|
||||
|
||||
// Get footer HTML
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerPage.craftState) {
|
||||
footerHtml = exportBodyHtml(footerPage.craftState).html;
|
||||
}
|
||||
} catch (e) { console.warn('Footer export failed:', e); }
|
||||
|
||||
// Compose full page: header + body + footer
|
||||
const composedBody = headerHtml + bodyHtml + footerHtml;
|
||||
const result = exportToHtml(serialized, {
|
||||
title: whpConfig?.siteName || 'Preview',
|
||||
includeFonts: true,
|
||||
});
|
||||
|
||||
// Replace the body in the full document with our composed version
|
||||
let html = result.html;
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
if (bodyMatch) {
|
||||
html = html.replace(bodyMatch[1], composedBody);
|
||||
}
|
||||
|
||||
// Make proxy URLs absolute so they work from the blob: context
|
||||
const origin = window.location.origin;
|
||||
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
|
||||
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Preview failed:', e);
|
||||
}
|
||||
}}>
|
||||
<i className="fa fa-eye" /> Preview
|
||||
</button>
|
||||
|
||||
{/* Draft/Published status badge */}
|
||||
{isWHP && isDraft && publishStatus !== 'published' && (
|
||||
<span className="publish-badge draft">
|
||||
<i className="fa fa-pencil" /> Draft
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'published' && (
|
||||
<span className="publish-badge published">
|
||||
<i className="fa fa-check-circle" /> Published
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save status indicator */}
|
||||
{saveStatus === 'saved' && (
|
||||
<span className="save-indicator saved">
|
||||
<i className="fa fa-check" /> Saved!
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Save Error
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Publish Error
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="topbar-btn primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
title="Save Draft"
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
||||
) : (
|
||||
<><i className="fa fa-save" /> Save</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isWHP && (
|
||||
<button
|
||||
className="topbar-btn publish"
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
title="Publish to live site"
|
||||
>
|
||||
{publishStatus === 'publishing' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
||||
) : (
|
||||
<><i className="fa fa-globe" /> Publish</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user