Add Craft.js site builder (v2) - complete rebuild from GrapesJS

Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets';
export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
useEffect(() => {
loadAssets();
}, [loadAssets]);
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files) return;
for (let i = 0; i < files.length; i++) {
await uploadAsset(files[i]);
}
}, [uploadAsset]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const copyUrl = useCallback((url: string) => {
navigator.clipboard.writeText(url).then(() => {
setCopiedUrl(url);
setTimeout(() => setCopiedUrl(null), 2000);
});
}, []);
const isImage = (type: string) =>
type.startsWith('image/') || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(type);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Upload button */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={loading}
style={{
width: '100%',
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
color: '#fff',
background: loading ? 'var(--color-bg-active)' : 'var(--color-accent)',
border: 'none',
borderRadius: 'var(--radius-md)',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'all var(--transition-fast)',
}}
>
{loading ? 'Uploading...' : 'Upload File'}
</button>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
style={{
padding: 20,
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
textAlign: 'center',
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
fontSize: 11,
transition: 'all var(--transition-fast)',
}}
>
Drop files here to upload
</div>
{/* Error message */}
{error && (
<div
style={{
padding: '6px 10px',
fontSize: 11,
color: 'var(--color-danger)',
background: 'rgba(239, 68, 68, 0.1)',
borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(239, 68, 68, 0.2)',
}}
>
{error}
</div>
)}
{/* Asset grid */}
{assets.length === 0 && !loading && (
<div
style={{
textAlign: 'center',
padding: 20,
color: 'var(--color-text-dim)',
fontSize: 12,
fontStyle: 'italic',
}}
>
No assets uploaded yet
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 6,
}}
>
{assets.map((asset) => (
<div
key={asset.name}
style={{
position: 'relative',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
cursor: 'pointer',
transition: 'border-color var(--transition-fast)',
}}
onClick={() => copyUrl(asset.url)}
title={`Click to copy URL: ${asset.url}`}
>
{/* Thumbnail */}
<div
style={{
width: '100%',
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-bg-base)',
overflow: 'hidden',
}}
>
{isImage(asset.type) ? (
<img
src={asset.url}
alt={asset.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
loading="lazy"
/>
) : (
<span
style={{
fontSize: 20,
color: 'var(--color-text-dim)',
}}
>
&#128196;
</span>
)}
</div>
{/* Name */}
<div
style={{
padding: '4px 6px',
fontSize: 10,
color: 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{copiedUrl === asset.url ? 'Copied!' : asset.name}
</div>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
deleteAsset(asset.name);
}}
title="Delete asset"
style={{
position: 'absolute',
top: 4,
right: 4,
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
color: '#fff',
background: 'rgba(0,0,0,0.6)',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
opacity: 0.7,
transition: 'opacity var(--transition-fast)',
}}
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
>
&#10005;
</button>
</div>
))}
</div>
{/* Loading indicator */}
{loading && assets.length > 0 && (
<div
style={{
textAlign: 'center',
padding: 10,
color: 'var(--color-text-dim)',
fontSize: 11,
}}
>
Loading...
</div>
)}
</div>
);
};
+211
View File
@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { useEditor } from '@craftjs/core';
import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout';
import { BackgroundSection } from '../../components/layout/BackgroundSection';
import { Heading } from '../../components/basic/Heading';
import { TextBlock } from '../../components/basic/TextBlock';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { Logo } from '../../components/basic/Logo';
import { Menu } from '../../components/basic/Menu';
import { Footer } from '../../components/basic/Footer';
import { Divider } from '../../components/basic/Divider';
import { Spacer } from '../../components/basic/Spacer';
import { Icon } from '../../components/basic/Icon';
import { HtmlBlock } from '../../components/basic/HtmlBlock';
import { ImageBlock } from '../../components/media/ImageBlock';
import { VideoBlock } from '../../components/media/VideoBlock';
import { MapEmbed } from '../../components/media/MapEmbed';
import { HeroSimple } from '../../components/sections/HeroSimple';
import { FeaturesGrid } from '../../components/sections/FeaturesGrid';
import { CallToAction } from '../../components/sections/CallToAction';
import { Countdown } from '../../components/sections/Countdown';
import { Testimonials } from '../../components/sections/Testimonials';
import { Accordion } from '../../components/sections/Accordion';
import { Tabs } from '../../components/sections/Tabs';
import { PricingTable } from '../../components/sections/PricingTable';
import { Gallery } from '../../components/sections/Gallery';
import { ContentSlider } from '../../components/sections/ContentSlider';
import { NumberCounter } from '../../components/sections/NumberCounter';
import { FormContainer } from '../../components/forms/FormContainer';
import { InputField } from '../../components/forms/InputField';
import { TextareaField } from '../../components/forms/TextareaField';
import { FormButton } from '../../components/forms/FormButton';
import { ContactForm } from '../../components/forms/ContactForm';
import { SubscribeForm } from '../../components/forms/SubscribeForm';
import { StarRating } from '../../components/basic/StarRating';
import { SocialLinks } from '../../components/basic/SocialLinks';
interface BlockDef {
id: string;
label: string;
icon: string;
component: React.ReactElement;
}
interface CategoryDef {
id: string;
label: string;
blocks: BlockDef[];
}
const categories: CategoryDef[] = [
{
id: 'basic',
label: 'Basic',
blocks: [
{ id: 'heading', label: 'Heading', icon: 'fa-header',
component: <Heading text="Your Heading" level="h2" style={{ fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px' }} /> },
{ id: 'text', label: 'Text', icon: 'fa-paragraph',
component: <TextBlock text="Add your text here. Click to edit." style={{ fontSize: '16px', lineHeight: '1.6', color: '#4b5563', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'button', label: 'Button', icon: 'fa-square',
component: <ButtonLink text="Click Me" href="#" style={{ display: 'inline-block', padding: '14px 32px', background: '#3b82f6', color: '#ffffff', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '16px', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', component: <Logo /> },
{ id: 'menu', label: 'Menu', icon: 'fa-bars', component: <Menu /> },
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', component: <Footer /> },
{ id: 'divider', label: 'Divider', icon: 'fa-minus', component: <Divider /> },
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', component: <Spacer /> },
{ id: 'icon', label: 'Icon', icon: 'fa-star', component: <Icon icon="fa-star" size="48px" color="#3b82f6" /> },
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', component: <StarRating /> },
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', component: <SocialLinks /> },
{ id: 'html-block', label: 'HTML', icon: 'fa-code', component: <HtmlBlock code="<div>Custom HTML</div>" /> },
],
},
{
id: 'layout',
label: 'Layout',
blocks: [
{ id: 'section', label: 'Section', icon: 'fa-window-maximize',
component: <Section style={{ padding: '60px 20px', backgroundColor: '#ffffff' }} /> },
{ id: 'container', label: 'Container', icon: 'fa-square-o',
component: <Container style={{ padding: '20px', minHeight: '100px' }} /> },
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop',
component: <ColumnLayout columns={1} split="100" /> },
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large',
component: <ColumnLayout columns={2} split="50-50" /> },
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th',
component: <ColumnLayout columns={3} split="33-33-33" /> },
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th',
component: <ColumnLayout columns={4} split="25-25-25-25" /> },
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th',
component: <ColumnLayout columns={5} split="20-20-20-20-20" /> },
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th',
component: <ColumnLayout columns={6} split="16-16-16-16-16-16" /> },
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="30-70" /> },
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="70-30" /> },
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', component: <BackgroundSection /> },
],
},
{
id: 'sections',
label: 'Sections',
blocks: [
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', component: <HeroSimple /> },
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', component: <FeaturesGrid /> },
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', component: <CallToAction /> },
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', component: <Accordion /> },
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', component: <Tabs /> },
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', component: <PricingTable /> },
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', component: <Gallery /> },
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o',
component: <Countdown targetDate={new Date(Date.now() + 30 * 86400000).toISOString()} /> },
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left',
component: <Testimonials testimonials={[{ quote: "Great service!", name: "John", title: "CEO", rating: 5 }]} layout="grid" columns={3} /> },
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', component: <ContentSlider /> },
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', component: <NumberCounter /> },
],
},
{
id: 'media',
label: 'Media',
blocks: [
{ id: 'image', label: 'Image', icon: 'fa-image',
component: <ImageBlock src="" alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
component: <VideoBlock videoUrl="" isBackground={false} /> },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
component: <MapEmbed address="New York, NY" zoom={14} height="400px" /> },
],
},
{
id: 'forms',
label: 'Forms',
blocks: [
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', component: <ContactForm /> },
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', component: <SubscribeForm /> },
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', component: <FormContainer /> },
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', component: <InputField /> },
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', component: <TextareaField /> },
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', component: <FormButton /> },
],
},
];
export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
categories.forEach((cat, index) => {
initial[cat.id] = index !== 0; // First category open
});
return initial;
});
const toggleCategory = (categoryId: string) => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
};
return (
<div>
{categories.map((category) => {
const isCollapsed = collapsed[category.id];
return (
<div key={category.id} className="block-category">
<div
className={`block-category-header ${isCollapsed ? 'collapsed' : ''}`}
onClick={() => toggleCategory(category.id)}
>
<span>{category.label}</span>
<i className="fa fa-chevron-down chevron" />
</div>
<div className={`block-category-items ${isCollapsed ? 'collapsed' : ''}`}>
<div className="block-grid">
{category.blocks.map((block) => (
<div
key={block.id}
className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
onDoubleClick={() => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
let canvasId = 'ROOT';
for (const nodeId of nodeIds) {
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
canvasId = nodeId;
break;
}
}
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
actions.addNodeTree(tree, canvasId);
} catch (e) {
console.error('Failed to add block:', e);
}
}}
title={`Drag or double-click to add ${block.label}`}
>
<i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
);
};
+137
View File
@@ -0,0 +1,137 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
interface LayerNodeProps {
nodeId: string;
depth: number;
}
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const { node, selectedId, actions } = useEditor((state) => {
const n = state.nodes[nodeId];
const selectedIds = state.events.selected;
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
return {
node: n,
selectedId: selId,
};
});
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
actions.selectNode(nodeId);
}, [actions, nodeId]);
if (!node) return null;
const isSelected = selectedId === nodeId;
const nodeType = node.data.type;
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
? (nodeType as any).resolvedName
: typeof nodeType === 'string' ? nodeType : undefined;
const displayName = node.data.displayName || resolvedName || 'Component';
const childNodeIds: string[] = node.data.nodes || [];
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
const allChildren = [...childNodeIds, ...linkedNodeIds];
const isRoot = nodeId === 'ROOT';
return (
<div>
<div
onClick={handleClick}
style={{
display: 'flex',
alignItems: 'center',
padding: '5px 8px',
paddingLeft: `${8 + depth * 16}px`,
fontSize: 12,
fontWeight: isSelected ? 600 : 400,
color: isSelected ? 'var(--color-accent)' : 'var(--color-text)',
background: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--color-accent)' : '2px solid transparent',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'none',
}}
onMouseEnter={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
}}
>
{/* Indentation indicator */}
{allChildren.length > 0 && (
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)' }}>
&#9660;
</span>
)}
{allChildren.length === 0 && (
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
&#9660;
</span>
)}
{/* Component type icon and name */}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isRoot ? 'Canvas (Root)' : displayName}
</span>
</div>
{/* Render children */}
{allChildren.map((childId) => (
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</div>
);
};
export const LayersPanel: React.FC = () => {
const { nodeIds } = useEditor((state) => {
return {
nodeIds: Object.keys(state.nodes),
};
});
const hasRoot = nodeIds.includes('ROOT');
if (!hasRoot) {
return (
<div className="panel-placeholder">
No content on canvas
</div>
);
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
margin: '-12px',
}}
>
<div
style={{
padding: '8px 12px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border)',
}}
>
Component Tree
</div>
<LayerNode nodeId="ROOT" depth={0} />
</div>
);
};
+40
View File
@@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { BlocksPanel } from './BlocksPanel';
import { PagesPanel } from './PagesPanel';
import { LayersPanel } from './LayersPanel';
import { AssetsPanel } from './AssetsPanel';
type LeftTab = 'blocks' | 'pages' | 'layers' | 'assets';
export const LeftPanel: React.FC = () => {
const [activeTab, setActiveTab] = useState<LeftTab>('blocks');
const tabs: { id: LeftTab; label: string }[] = [
{ id: 'blocks', label: 'Blocks' },
{ id: 'pages', label: 'Pages' },
{ id: 'layers', label: 'Layers' },
{ id: 'assets', label: 'Assets' },
];
return (
<div className="panel-left">
<div className="panel-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`panel-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="panel-content">
{activeTab === 'blocks' && <BlocksPanel />}
{activeTab === 'pages' && <PagesPanel />}
{activeTab === 'layers' && <LayersPanel />}
{activeTab === 'assets' && <AssetsPanel />}
</div>
</div>
);
};
+470
View File
@@ -0,0 +1,470 @@
import React, { useState } from 'react';
import { usePages } from '../../state/PageContext';
export const PagesPanel: React.FC = () => {
const {
pages,
activePageId,
isEditingHeader,
isEditingFooter,
switchPage,
editHeader,
editFooter,
addPage,
deletePage,
renamePage,
} = usePages();
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState('');
const [newSlug, setNewSlug] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editSlug, setEditSlug] = useState('');
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const handleAdd = () => {
if (!newName.trim()) return;
addPage(newName.trim(), newSlug.trim());
setNewName('');
setNewSlug('');
setIsAdding(false);
};
const handleRename = (pageId: string) => {
if (!editName.trim()) return;
renamePage(pageId, editName.trim(), editSlug.trim());
setEditingId(null);
};
const handleDelete = (pageId: string) => {
deletePage(pageId);
setDeleteConfirmId(null);
};
const startEditing = (page: { id: string; name: string; slug: string }) => {
setEditingId(page.id);
setEditName(page.name);
setEditSlug(page.slug);
setDeleteConfirmId(null);
};
const autoSlug = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
};
/* ---------- Zone button style ---------- */
const zoneButtonStyle = (isActive: boolean): React.CSSProperties => ({
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '10px 12px',
fontSize: 12,
fontWeight: 600,
color: isActive ? '#f59e0b' : '#fbbf24',
background: isActive ? 'rgba(245, 158, 11, 0.15)' : 'rgba(245, 158, 11, 0.06)',
border: `1px solid ${isActive ? 'rgba(245, 158, 11, 0.5)' : 'rgba(245, 158, 11, 0.2)'}`,
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
textAlign: 'left' as const,
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header/Footer zone buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
<button
onClick={editHeader}
style={zoneButtonStyle(isEditingHeader)}
>
<i className="fa fa-window-maximize" style={{ fontSize: 13 }} />
<div style={{ flex: 1 }}>
<div>Edit Header</div>
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
Appears on all pages
</div>
</div>
{isEditingHeader && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)}
</button>
<button
onClick={editFooter}
style={zoneButtonStyle(isEditingFooter)}
>
<i className="fa fa-window-minimize" style={{ fontSize: 13 }} />
<div style={{ flex: 1 }}>
<div>Edit Footer</div>
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
Appears on all pages
</div>
</div>
{isEditingFooter && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)}
</button>
</div>
{/* Section label for pages */}
<div style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-text-dim)',
padding: '0 2px',
}}>
Pages
</div>
{/* Page list */}
{pages.map((page) => (
<div key={page.id}>
{editingId === page.id ? (
/* Editing mode */
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-accent)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<input
type="text"
value={editName}
onChange={(e) => {
setEditName(e.target.value);
setEditSlug(autoSlug(e.target.value));
}}
placeholder="Page name"
className="control-input"
style={{ fontSize: 12 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(page.id);
if (e.key === 'Escape') setEditingId(null);
}}
/>
<input
type="text"
value={editSlug}
onChange={(e) => setEditSlug(e.target.value)}
placeholder="page-slug"
className="control-input"
style={{ fontSize: 11 }}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleRename(page.id)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: 'var(--color-accent)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Save
</button>
<button
onClick={() => setEditingId(null)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : deleteConfirmId === page.id ? (
/* Delete confirmation */
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-danger)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
Delete "{page.name}"?
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleDelete(page.id)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: 'var(--color-danger)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Delete
</button>
<button
onClick={() => setDeleteConfirmId(null)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
/* Normal page item */
<div
onClick={() => switchPage(page.id)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
background:
page.id === activePageId
? 'var(--color-accent-subtle)'
: 'var(--color-bg-elevated)',
border: `1px solid ${page.id === activePageId ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: page.id === activePageId ? 600 : 500,
color:
page.id === activePageId
? 'var(--color-accent)'
: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{page.name}
</div>
<div
style={{
fontSize: 10,
color: 'var(--color-text-dim)',
marginTop: 2,
}}
>
/{page.slug}
</div>
</div>
<div
style={{ display: 'flex', gap: 4, flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => startEditing(page)}
title="Rename"
style={{
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
&#9998;
</button>
{pages.length > 1 && (
<button
onClick={() => setDeleteConfirmId(page.id)}
title="Delete"
style={{
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
&#10005;
</button>
)}
</div>
</div>
)}
</div>
))}
{/* Add page section */}
{isAdding ? (
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<input
type="text"
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setNewSlug(autoSlug(e.target.value));
}}
placeholder="Page name"
className="control-input"
style={{ fontSize: 12 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleAdd();
if (e.key === 'Escape') {
setIsAdding(false);
setNewName('');
setNewSlug('');
}
}}
/>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="page-slug"
className="control-input"
style={{ fontSize: 11 }}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={handleAdd}
disabled={!newName.trim()}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: newName.trim() ? 'var(--color-accent)' : 'var(--color-bg-active)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: newName.trim() ? 'pointer' : 'not-allowed',
}}
>
Add Page
</button>
<button
onClick={() => {
setIsAdding(false);
setNewName('');
setNewSlug('');
}}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setIsAdding(true)}
style={{
width: '100%',
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-accent)',
background: 'var(--color-accent-subtle)',
border: '1px dashed var(--color-accent)',
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
+ Add Page
</button>
)}
</div>
);
};