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,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