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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user