/**
* Site Builder - GrapesJS Editor Configuration
*/
(function() {
'use strict';
// Storage key for localStorage
const STORAGE_KEY = 'sitebuilder-project';
// Initialize GrapesJS Editor
const editor = grapesjs.init({
container: '#gjs',
height: '100%',
width: 'auto',
fromElement: false,
// Storage configuration - localStorage
storageManager: {
type: 'local',
autosave: true,
autoload: true,
stepsBeforeSave: 1,
options: {
local: {
key: STORAGE_KEY
}
}
},
// Device manager for responsive preview
deviceManager: {
devices: [
{
name: 'Desktop',
width: ''
},
{
name: 'Tablet',
width: '768px',
widthMedia: '992px'
},
{
name: 'Mobile',
width: '375px',
widthMedia: '480px'
}
]
},
// Layer manager configuration
layerManager: {
appendTo: '#layers-container'
},
// Block manager configuration
blockManager: {
appendTo: '#blocks-container'
},
// Style manager configuration
styleManager: {
appendTo: '#advanced-styles',
sectors: [
{
name: 'Dimension',
open: false,
properties: [
'width', 'min-width', 'max-width',
'height', 'min-height', 'max-height',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left'
]
},
{
name: 'Typography',
open: false,
properties: [
{
property: 'font-family',
type: 'select',
options: [
{ id: 'Inter, sans-serif', label: 'Inter' },
{ id: 'Roboto, sans-serif', label: 'Roboto' },
{ id: 'Open Sans, sans-serif', label: 'Open Sans' },
{ id: 'Poppins, sans-serif', label: 'Poppins' },
{ id: 'Montserrat, sans-serif', label: 'Montserrat' },
{ id: 'Playfair Display, serif', label: 'Playfair Display' },
{ id: 'Merriweather, serif', label: 'Merriweather' },
{ id: 'Source Code Pro, monospace', label: 'Source Code Pro' },
{ id: 'Arial, Helvetica, sans-serif', label: 'Arial' },
{ id: 'Georgia, serif', label: 'Georgia' },
{ id: 'Times New Roman, serif', label: 'Times New Roman' }
]
},
'font-size',
{
property: 'font-weight',
type: 'select',
options: [
{ id: '100', label: 'Thin (100)' },
{ id: '200', label: 'Extra Light (200)' },
{ id: '300', label: 'Light (300)' },
{ id: '400', label: 'Normal (400)' },
{ id: '500', label: 'Medium (500)' },
{ id: '600', label: 'Semi Bold (600)' },
{ id: '700', label: 'Bold (700)' },
{ id: '800', label: 'Extra Bold (800)' },
{ id: '900', label: 'Black (900)' }
]
},
{
property: 'letter-spacing',
type: 'number',
units: ['px', 'em', 'rem'],
default: '0',
step: 0.1
},
{
property: 'line-height',
type: 'number',
units: ['px', 'em', '%', ''],
default: 'normal',
step: 0.1
},
{
property: 'text-align',
type: 'radio',
options: [
{ id: 'left', label: 'Left', className: 'fa fa-align-left' },
{ id: 'center', label: 'Center', className: 'fa fa-align-center' },
{ id: 'right', label: 'Right', className: 'fa fa-align-right' },
{ id: 'justify', label: 'Justify', className: 'fa fa-align-justify' }
]
},
'text-decoration', 'text-transform', 'color'
]
},
{
name: 'Background',
open: false,
properties: [
'background-color', 'background-image', 'background-repeat',
'background-position', 'background-size', 'background-attachment'
]
},
{
name: 'Border',
open: false,
properties: [
'border-radius', 'border-radius-top-left', 'border-radius-top-right',
'border-radius-bottom-left', 'border-radius-bottom-right',
'border', 'border-width', 'border-style', 'border-color'
]
},
{
name: 'Effects',
open: false,
properties: [
'opacity', 'box-shadow', 'transition'
]
},
{
name: 'Layout',
open: false,
properties: [
'display', 'position', 'top', 'right', 'bottom', 'left',
'flex-direction', 'flex-wrap', 'justify-content', 'align-items',
'align-content', 'gap', 'order', 'flex-basis', 'flex-grow', 'flex-shrink'
]
}
]
},
// Trait manager configuration
traitManager: {
appendTo: '#traits-container'
},
// Selector manager (CSS classes)
selectorManager: {
appendTo: '#advanced-styles',
componentFirst: true
},
// Plugins - using global function references from CDN scripts
plugins: [
window['gjs-blocks-basic'],
window['grapesjs-preset-webpage'],
window['grapesjs-plugin-forms'],
window['grapesjs-style-gradient']
].filter(Boolean), // Filter out any undefined plugins
pluginsOpts: {
[window['gjs-blocks-basic']]: {
flexGrid: true,
blocks: ['column1', 'column2', 'column3', 'column3-7', 'text', 'link', 'map']
},
[window['grapesjs-preset-webpage']]: {
modalImportTitle: 'Import Template',
modalImportLabel: '
Paste HTML/CSS here
',
modalImportContent: '',
importViewerRecords: true,
textCleanCanvas: 'Are you sure you want to clear the canvas?',
showStylesOnChange: true,
useCustomTheme: false,
blocks: ['link-block', 'quote', 'text-basic']
},
[window['grapesjs-plugin-forms']]: {
blocks: ['form', 'input', 'textarea', 'select', 'button', 'label', 'checkbox', 'radio']
},
[window['grapesjs-style-gradient']]: {
colorPicker: 'default'
}
},
// Canvas configuration
canvas: {
styles: [
// Google Fonts - Popular choices
'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;500;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap',
// Responsive images and base styles injected inline
'data:text/css,' + encodeURIComponent(`
/* Responsive images */
img {
max-width: 100%;
height: auto;
}
/* Responsive video containers */
video {
max-width: 100%;
height: auto;
}
/* Responsive columns on mobile */
@media (max-width: 480px) {
.row {
flex-direction: column !important;
}
.row .cell {
flex-basis: 100% !important;
width: 100% !important;
}
}
/* Editor-only anchor visualization */
.editor-anchor {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
border: 1px dashed #9ca3af;
padding: 4px 8px;
background: rgba(59,130,246,0.05);
border-radius: 4px;
}
.editor-anchor .anchor-icon {
font-size: 14px;
color: #6b7280;
line-height: 1;
}
.editor-anchor .anchor-name-input {
border: none;
background: transparent;
color: #374151;
font-size: 12px;
font-family: Inter, sans-serif;
font-weight: 500;
padding: 2px 4px;
outline: none;
min-width: 80px;
}
.editor-anchor .anchor-name-input:focus {
background: rgba(255,255,255,0.5);
border-radius: 2px;
}
`)
],
scripts: []
}
});
// ==========================================
// UI Elements
// ==========================================
const saveStatus = document.getElementById('save-status');
const statusText = saveStatus.querySelector('.status-text');
// ==========================================
// Add Custom Blocks (Columns, Hero, etc.)
// ==========================================
const blockManager = editor.BlockManager;
// Section Block
blockManager.add('section', {
label: 'Section',
category: 'Layout',
content: {
tagName: 'section',
style: {
'padding': '60px 20px',
'min-height': '200px',
'background-color': '#ffffff'
},
components: [
{
tagName: 'div',
style: {
'max-width': '1200px',
'margin': '0 auto'
},
components: []
}
]
},
attributes: { class: 'fa fa-columns' }
});
// Logo Block
blockManager.add('logo', {
label: 'Logo',
category: 'Layout',
content: `
S
SiteName
`,
attributes: { class: 'fa fa-bookmark' }
});
// Navigation Menu (will be updated dynamically based on pages)
blockManager.add('navbar', {
label: 'Navigation',
category: 'Layout',
content: {
type: 'navbar',
tagName: 'nav',
attributes: { class: 'site-navbar', 'data-dynamic-nav': 'true' },
style: {
'display': 'flex',
'align-items': 'center',
'justify-content': 'space-between',
'padding': '16px 24px',
'background': '#ffffff',
'border-bottom': '1px solid #e5e7eb'
},
components: [
{
tagName: 'a',
attributes: { href: '#', class: 'nav-logo' },
style: {
'font-size': '20px',
'font-weight': '700',
'color': '#1f2937',
'text-decoration': 'none',
'font-family': 'Inter, sans-serif'
},
content: 'Logo'
},
{
tagName: 'div',
attributes: { class: 'nav-links' },
style: {
'display': 'flex',
'gap': '24px',
'align-items': 'center'
},
components: [
{
tagName: 'a',
attributes: { href: '#' },
style: {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: 'Home'
},
{
tagName: 'a',
attributes: { href: '#' },
style: {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: 'About'
},
{
tagName: 'a',
attributes: { href: '#' },
style: {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: 'Contact'
},
{
tagName: 'a',
attributes: { href: '#', class: 'nav-cta' },
style: {
'display': 'inline-block',
'padding': '10px 20px',
'background': '#3b82f6',
'color': '#fff',
'text-decoration': 'none',
'border-radius': '6px',
'font-size': '14px',
'font-weight': '500',
'font-family': 'Inter, sans-serif'
},
content: 'Get Started'
}
]
}
]
},
attributes: { class: 'fa fa-bars' }
});
// Section with Background (Image/Video + Overlay)
blockManager.add('section-bg', {
label: 'Section (Background)',
category: 'Layout',
content: {
tagName: 'section',
attributes: { class: 'section-with-bg', 'data-bg-section': 'true' },
style: {
'position': 'relative',
'min-height': '400px',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center',
'background-image': 'url(https://images.unsplash.com/photo-1557683316-973673baf926?w=1920)',
'background-size': 'cover',
'background-position': 'center',
'padding': '60px 20px',
'overflow': 'hidden'
},
components: [
// Overlay
{
tagName: 'div',
attributes: { class: 'bg-overlay' },
style: {
'position': 'absolute',
'top': '0',
'left': '0',
'right': '0',
'bottom': '0',
'background': 'rgba(0,0,0,0.5)',
'z-index': '1'
},
selectable: true,
hoverable: true
},
// Content container
{
tagName: 'div',
attributes: { class: 'bg-content' },
style: {
'position': 'relative',
'z-index': '2',
'max-width': '800px',
'text-align': 'center'
},
components: [
{
tagName: 'h2',
style: {
'color': '#ffffff',
'font-size': '36px',
'font-weight': '700',
'margin-bottom': '16px',
'font-family': 'Inter, sans-serif'
},
content: 'Your Heading Here'
},
{
tagName: 'p',
style: {
'color': 'rgba(255,255,255,0.9)',
'font-size': '18px',
'line-height': '1.6',
'font-family': 'Inter, sans-serif'
},
content: 'Add your content here. This section supports background images and videos with customizable overlay.'
}
]
}
]
},
attributes: { class: 'fa fa-image' }
});
// Section with Video Background
blockManager.add('section-video-bg', {
label: 'Section (Video BG)',
category: 'Layout',
content: `
▶
Click this section, then add Video URL in Settings →
Video Background
This section has a looping video background with an overlay.
`,
attributes: { class: 'fa fa-play-circle' }
});
// Footer
blockManager.add('footer', {
label: 'Footer',
category: 'Layout',
content: ``,
attributes: { class: 'fa fa-window-minimize' }
});
// Column Layouts
blockManager.add('column-1', {
label: '1 Column',
category: 'Layout',
content: ``,
attributes: { class: 'gjs-fonts gjs-f-b1' }
});
blockManager.add('column-2', {
label: '2 Columns',
category: 'Layout',
content: ``,
attributes: { class: 'gjs-fonts gjs-f-b2' }
});
blockManager.add('column-3', {
label: '3 Columns',
category: 'Layout',
content: ``,
attributes: { class: 'gjs-fonts gjs-f-b3' }
});
blockManager.add('column-4', {
label: '4 Columns',
category: 'Layout',
content: ``,
attributes: { class: 'gjs-fonts gjs-f-b4' }
});
blockManager.add('column-3-7', {
label: '2 Columns 3/7',
category: 'Layout',
content: ``,
attributes: { class: 'gjs-fonts gjs-f-b37' }
});
// Image Block
blockManager.add('image-block', {
label: 'Image',
category: 'Media',
content: '
',
attributes: { class: 'fa fa-image' }
});
// Unified Video Block (YouTube, Vimeo, or direct file)
blockManager.add('video-block', {
label: 'Video',
category: 'Media',
content: `
▶
Select container & add Video URL in Settings
Supports YouTube, Vimeo, or direct video files
`,
attributes: { class: 'fa fa-play-circle' }
});
// Hero with Image Background
blockManager.add('hero-image', {
label: 'Hero (Image)',
category: 'Sections',
content: `
Your Headline Here
Add your compelling subheadline or description text here to engage your visitors.
Get Started
`,
attributes: { class: 'fa fa-image' }
});
// Hero with Video Background
blockManager.add('hero-video', {
label: 'Hero (Video)',
category: 'Sections',
content: `
Video Background Hero
Create stunning video backgrounds for your hero sections.
Learn More
`,
attributes: { class: 'fa fa-play-circle' }
});
// Simple Hero Section
blockManager.add('hero-simple', {
label: 'Hero (Simple)',
category: 'Sections',
content: ``,
attributes: { class: 'fa fa-star' }
});
// Features Section
blockManager.add('features-section', {
label: 'Features Grid',
category: 'Sections',
content: `
Features
Feature One
Description of your first amazing feature goes here.
Feature Two
Description of your second amazing feature goes here.
Feature Three
Description of your third amazing feature goes here.
`,
attributes: { class: 'fa fa-th-large' }
});
// Testimonials Section
blockManager.add('testimonials-section', {
label: 'Testimonials',
category: 'Sections',
content: `
What People Say
Hear from our satisfied customers
★
★
★
★
★
"This product has completely transformed how we work. The results speak for themselves."
JD
John Doe
CEO, Company Inc
★
★
★
★
★
"Exceptional quality and outstanding customer service. I couldn't be happier with my experience."
JS
Jane Smith
Designer, Studio Co
★
★
★
★
★
"A game-changer for our business. The ROI has been incredible from day one."
MB
Mike Brown
Founder, StartupXYZ
`,
attributes: { class: 'fa fa-comments' }
});
// Pricing Section
blockManager.add('pricing-section', {
label: 'Pricing Table',
category: 'Sections',
content: `
Simple Pricing
Choose the plan that's right for you
Starter
Perfect for individuals
$9
/month
- ✓ 5 Projects
- ✓ Basic Support
- ✓ 1GB Storage
- ✓ Community Access
Get Started
MOST POPULAR
Professional
Best for growing teams
$29
/month
- ✓ Unlimited Projects
- ✓ Priority Support
- ✓ 10GB Storage
- ✓ Advanced Analytics
Get Started
Enterprise
For large organizations
$99
/month
- ✓ Everything in Pro
- ✓ Dedicated Support
- ✓ Unlimited Storage
- ✓ Custom Integrations
Contact Sales
`,
attributes: { class: 'fa fa-credit-card' }
});
// Contact Section
blockManager.add('contact-section', {
label: 'Contact Section',
category: 'Sections',
content: `
Get in Touch
Have questions? We'd love to hear from you. Send us a message and we'll respond as soon as possible.
📍
Address
123 Business Street, City, ST 12345
`,
attributes: { class: 'fa fa-envelope' }
});
// Call to Action Section
blockManager.add('cta-section', {
label: 'Call to Action',
category: 'Sections',
content: `
Ready to Get Started?
Join thousands of satisfied customers and take your business to the next level.
`,
attributes: { class: 'fa fa-bullhorn' }
});
// Text Block
blockManager.add('text-block', {
label: 'Text',
category: 'Basic',
content: 'Insert your text here. You can edit this content directly.
',
attributes: { class: 'gjs-fonts gjs-f-text' }
});
// Heading Block
blockManager.add('heading', {
label: 'Heading',
category: 'Basic',
content: 'Heading
',
attributes: { class: 'fa fa-header' }
});
// Button Block
blockManager.add('button-block', {
label: 'Button',
category: 'Basic',
content: 'Button',
attributes: { class: 'fa fa-link' }
});
// Divider Block - Resizable with height control
blockManager.add('divider', {
label: 'Divider',
category: 'Basic',
content: {
tagName: 'hr',
style: {
'border': 'none',
'border-top': '2px solid #e5e7eb',
'margin': '20px 0',
'width': '100%',
'height': '0'
},
resizable: {
tl: 0, tc: 0, tr: 0,
cl: 1, cr: 1,
bl: 0, bc: 0, br: 0,
keyWidth: 'width'
}
},
attributes: { class: 'fa fa-minus' }
});
// Spacer Block
blockManager.add('spacer', {
label: 'Spacer',
category: 'Basic',
content: '',
attributes: { class: 'fa fa-arrows-alt-v' }
});
// Anchor Point Block
blockManager.add('anchor-point', {
label: 'Anchor Point',
category: 'Basic',
content: `
⚓
`,
attributes: { class: 'fa fa-anchor' }
});
// PDF / File Embed Block
blockManager.add('file-embed', {
label: 'File / PDF',
category: 'Media',
content: `
📄
Select this element, then enter File URL in Settings
Supports PDF, DOC, and other embeddable files
`,
attributes: { class: 'fa fa-file-pdf' }
});
// Text Box Block (for overlaying on backgrounds)
blockManager.add('text-box', {
label: 'Text Box',
category: 'Basic',
content: {
tagName: 'div',
attributes: { class: 'text-box' },
style: {
'padding': '24px',
'background': 'rgba(255,255,255,0.95)',
'border-radius': '8px',
'max-width': '600px',
'box-shadow': '0 4px 6px rgba(0,0,0,0.1)'
},
components: [
{
tagName: 'h3',
style: {
'color': '#1f2937',
'font-size': '24px',
'font-weight': '600',
'margin-bottom': '12px',
'font-family': 'Inter, sans-serif'
},
content: 'Text Box Title'
},
{
tagName: 'p',
style: {
'color': '#4b5563',
'font-size': '16px',
'line-height': '1.6',
'font-family': 'Inter, sans-serif'
},
content: 'Add your content here. This text box can be placed over images or video backgrounds.'
}
]
},
attributes: { class: 'fa fa-file-text' }
});
// ==========================================
// Enhanced Block Library
// ==========================================
// Image Gallery
blockManager.add('image-gallery', {
label: 'Image Gallery',
category: 'Sections',
content: ``,
attributes: { class: 'fa fa-th' }
});
// FAQ Accordion
blockManager.add('faq-section', {
label: 'FAQ Accordion',
category: 'Sections',
content: `
Frequently Asked Questions
What is your return policy?
We offer a 30-day money-back guarantee on all purchases. If you're not satisfied, contact our support team for a full refund.
How long does shipping take?
Standard shipping takes 5-7 business days. Express shipping is available for 2-3 business day delivery.
Do you offer customer support?
Yes! Our support team is available 24/7 via email and live chat. Phone support is available Mon-Fri, 9am-5pm EST.
Can I cancel my subscription?
You can cancel your subscription at any time from your account settings. No cancellation fees apply.
`,
attributes: { class: 'fa fa-question-circle' }
});
// Stats/Counter Section
blockManager.add('stats-section', {
label: 'Stats Counter',
category: 'Sections',
content: ``,
attributes: { class: 'fa fa-bar-chart' }
});
// Team Grid
blockManager.add('team-section', {
label: 'Team Grid',
category: 'Sections',
content: `
Meet Our Team
The talented people behind our success
AJ
Alex Johnson
CEO & Founder
Visionary leader with 15+ years of experience.
SK
Sarah Kim
Lead Designer
Award-winning designer creating beautiful experiences.
MP
Mike Patel
CTO
Full-stack engineer building scalable systems.
`,
attributes: { class: 'fa fa-users' }
});
// Newsletter Signup
blockManager.add('newsletter-section', {
label: 'Newsletter',
category: 'Sections',
content: ``,
attributes: { class: 'fa fa-newspaper' }
});
// Logo Cloud / Trusted By
blockManager.add('logo-cloud', {
label: 'Logo Cloud',
category: 'Sections',
content: `
Trusted by leading companies
Company 1
Company 2
Company 3
Company 4
Company 5
`,
attributes: { class: 'fa fa-building' }
});
// ==========================================
// Custom Component Types for Better Editing
// ==========================================
// Helper to convert YouTube/Vimeo URLs to embed format
function convertToEmbedUrl(url, isBackground = false) {
if (!url) return null;
// YouTube: youtube.com/watch?v=ID or youtu.be/ID
const youtubeMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
if (youtubeMatch) {
const videoId = youtubeMatch[1];
// Use youtube-nocookie.com to avoid Error 153 (referrer requirements)
// The referrerpolicy attribute in the iframe handles the referrer header
if (isBackground) {
// Background video: autoplay, muted, looped, no controls
return {
type: 'youtube',
url: `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&loop=1&playlist=${videoId}&controls=0&modestbranding=1&rel=0&showinfo=0`
};
} else {
// Regular video: user controls, no autoplay
return {
type: 'youtube',
url: `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`
};
}
}
// Vimeo: vimeo.com/ID or player.vimeo.com/video/ID
const vimeoMatch = url.match(/(?:vimeo\.com\/|player\.vimeo\.com\/video\/)(\d+)/);
if (vimeoMatch) {
if (isBackground) {
// Background video parameters
return { type: 'vimeo', url: `https://player.vimeo.com/video/${vimeoMatch[1]}?muted=1&loop=1&background=1&autoplay=1` };
} else {
// Regular video with controls
return { type: 'vimeo', url: `https://player.vimeo.com/video/${vimeoMatch[1]}` };
}
}
// Direct video file
if (url.match(/\.(mp4|webm|ogg|mov)(\?.*)?$/i)) {
return { type: 'file', url: url };
}
// Assume it's an embed URL if nothing else matches
return { type: 'embed', url: url };
}
// Helper to apply video URL to a video wrapper component
function applyVideoUrl(component, url) {
if (!url) return;
// Detect if this is a background video by checking for bg-video classes
const iframe = component.components().find(c => c.getClasses().includes('video-frame') || c.getClasses().includes('bg-video-frame'));
const isBackground = iframe && iframe.getClasses().includes('bg-video-frame');
const result = convertToEmbedUrl(url, isBackground);
if (!result) return;
console.log('Applying video URL:', url);
console.log('Converted to:', result.url);
console.log('Video type:', result.type);
console.log('Is background:', isBackground);
// Find child elements (iframe already found above)
const video = component.components().find(c => c.getClasses().includes('video-player') || c.getClasses().includes('bg-video-player'));
const placeholder = component.components().find(c => c.getClasses().includes('video-placeholder') || c.getClasses().includes('bg-video-placeholder'));
if (result.type === 'file') {
// Use HTML5 video
if (video) {
video.addAttributes({ src: result.url });
video.addStyle({ display: 'block' });
const videoEl = video.getEl();
if (videoEl) {
videoEl.src = result.url;
videoEl.style.display = 'block';
}
}
if (iframe) {
iframe.addStyle({ display: 'none' });
const iframeEl = iframe.getEl();
if (iframeEl) iframeEl.style.display = 'none';
}
} else {
// Use iframe for YouTube/Vimeo/embeds
if (iframe) {
iframe.addAttributes({ src: result.url });
iframe.addStyle({ display: 'block' });
const iframeEl = iframe.getEl();
if (iframeEl) {
iframeEl.src = result.url;
iframeEl.style.display = 'block';
}
}
if (video) {
video.addStyle({ display: 'none' });
const videoEl = video.getEl();
if (videoEl) videoEl.style.display = 'none';
}
}
// Hide placeholder
if (placeholder) {
placeholder.addStyle({ display: 'none' });
const placeholderEl = placeholder.getEl();
if (placeholderEl) placeholderEl.style.display = 'none';
}
}
// Video Wrapper Component (for Video block)
editor.DomComponents.addType('video-wrapper', {
isComponent: el => el.getAttribute && el.getAttribute('data-video-wrapper') === 'true',
model: {
defaults: {
traits: [
{
type: 'text',
label: 'Video URL',
name: 'videoUrl',
placeholder: 'YouTube, Vimeo, or .mp4 URL'
},
{
type: 'button',
label: '',
text: 'Apply Video',
full: true,
command: (editor) => {
const selected = editor.getSelected();
if (!selected) return;
const url = selected.getAttributes().videoUrl;
if (!url) {
alert('Please enter a Video URL first');
return;
}
console.log('Apply Video button clicked (regular video), URL:', url);
applyVideoUrl(selected, url);
alert('Video applied! If you see an error, the video owner may have disabled embedding.');
}
}
]
},
init() {
this.on('change:attributes:videoUrl', this.onVideoUrlChange);
// Make child elements non-selectable so clicks bubble to wrapper
this.components().forEach(child => {
child.set({
selectable: false,
hoverable: false,
editable: false,
draggable: false,
droppable: false,
badgable: false,
layerable: true // Still show in layers panel
});
});
},
onVideoUrlChange() {
const url = this.getAttributes().videoUrl;
applyVideoUrl(this, url);
}
}
});
// Background Video Wrapper Component (for Section Video BG)
// NOTE: No traits here! Users should set Video URL on the parent section element.
editor.DomComponents.addType('bg-video-wrapper', {
isComponent: el => el.getAttribute && el.getAttribute('data-bg-video') === 'true',
model: {
defaults: {
draggable: false, // Prevent moving the video wrapper independently
selectable: false, // Don't let users select it directly
hoverable: false, // Don't highlight on hover
traits: [] // No traits - configured via parent section
},
init() {
// Listen for videoUrl attribute changes (set by parent section)
this.on('change:attributes:videoUrl', this.onVideoUrlChange);
},
onVideoUrlChange() {
const url = this.getAttributes().videoUrl;
applyVideoUrl(this, url);
}
}
});
// Video Section Component (outer section with video background)
editor.DomComponents.addType('video-section', {
isComponent: el => el.getAttribute && el.getAttribute('data-video-section') === 'true',
model: {
defaults: {
traits: [
{
type: 'text',
label: 'Video URL',
name: 'videoUrl',
placeholder: 'YouTube, Vimeo, or .mp4 URL'
},
{
type: 'button',
label: '',
text: 'Apply Video',
full: true,
command: (editor) => {
const selected = editor.getSelected();
if (!selected) return;
const url = selected.getAttributes().videoUrl;
if (!url) {
alert('Please enter a Video URL first');
return;
}
console.log('Apply Video button clicked, URL:', url);
// Find the bg-video-wrapper child
const videoWrapper = selected.components().find(c =>
c.getAttributes()['data-bg-video'] === 'true'
);
if (videoWrapper) {
videoWrapper.addAttributes({ videoUrl: url });
applyVideoUrl(videoWrapper, url);
alert('Video applied! If you see an error, the video owner may have disabled embedding.');
} else {
alert('Error: Video wrapper not found');
}
}
}
]
},
init() {
// Listen for attribute changes
this.on('change:attributes:videoUrl', () => {
const url = this.getAttributes().videoUrl;
if (!url) return;
console.log('Video URL changed:', url);
// Find the bg-video-wrapper child and apply the video URL to it
const videoWrapper = this.components().find(c =>
c.getAttributes()['data-bg-video'] === 'true'
);
console.log('Video wrapper found:', !!videoWrapper);
if (videoWrapper) {
videoWrapper.addAttributes({ videoUrl: url });
applyVideoUrl(videoWrapper, url);
}
});
// Make child elements non-selectable so clicking them selects the parent section
setTimeout(() => {
this.components().forEach(child => {
// Skip the content layer (users should be able to edit text)
const classes = child.getClasses();
if (!classes.includes('bg-content')) {
child.set({
selectable: false,
hoverable: false,
editable: false
});
}
});
}, 100);
}
}
});
// Anchor Point Component
editor.DomComponents.addType('anchor-point', {
isComponent: el => el.getAttribute && el.getAttribute('data-anchor') === 'true',
model: {
defaults: {
traits: [
{
type: 'text',
label: 'Anchor Name',
name: 'id',
placeholder: 'e.g. about-us'
}
]
},
init() {
// Make child elements (icon and input) non-selectable
// This prevents users from accidentally selecting/deleting them
this.components().forEach(child => {
child.set({
selectable: false,
hoverable: false,
editable: false,
draggable: false,
droppable: false,
badgable: false,
layerable: false,
removable: false
});
});
}
}
});
// File Embed Component
editor.DomComponents.addType('file-embed', {
isComponent: el => el.getAttribute && el.getAttribute('data-file-embed') === 'true',
model: {
defaults: {
traits: [
{
type: 'text',
label: 'File URL',
name: 'fileUrl',
placeholder: 'https://example.com/file.pdf'
},
{
type: 'number',
label: 'Height (px)',
name: 'frameHeight',
placeholder: '600',
default: 600
},
{
type: 'button',
label: '',
text: 'Apply File',
full: true,
command: (editor) => {
const selected = editor.getSelected();
if (!selected) return;
const url = selected.getAttributes().fileUrl;
const height = selected.getAttributes().frameHeight || 600;
if (!url) {
alert('Please enter a File URL first');
return;
}
const iframe = selected.components().find(c => c.getClasses().includes('file-embed-frame'));
const placeholder = selected.components().find(c => c.getClasses().includes('file-embed-placeholder'));
if (iframe) {
// For Google Docs viewer for non-PDF files
let embedUrl = url;
if (!url.match(/\.pdf(\?.*)?$/i) && !url.includes('docs.google.com')) {
embedUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(url)}&embedded=true`;
}
iframe.addAttributes({ src: embedUrl });
iframe.addStyle({ display: 'block', height: height + 'px' });
const el = iframe.getEl();
if (el) { el.src = embedUrl; el.style.display = 'block'; el.style.height = height + 'px'; }
}
if (placeholder) {
placeholder.addStyle({ display: 'none' });
const el = placeholder.getEl();
if (el) el.style.display = 'none';
}
}
}
]
},
init() {
// Make child elements non-selectable so clicks bubble to wrapper
this.components().forEach(child => {
child.set({
selectable: false,
hoverable: false,
editable: false,
draggable: false,
droppable: false,
badgable: false,
layerable: false,
removable: false
});
});
}
}
});
// Logo Component with image support
editor.DomComponents.addType('site-logo', {
isComponent: el => el.classList && el.classList.contains('site-logo'),
model: {
defaults: {
traits: [
{
type: 'text',
label: 'Logo Text',
name: 'logoText',
placeholder: 'SiteName'
},
{
type: 'text',
label: 'Logo Image URL',
name: 'logoImage',
placeholder: 'https://example.com/logo.png'
},
{
type: 'select',
label: 'Logo Mode',
name: 'logoMode',
options: [
{ id: 'text', name: 'Text Only' },
{ id: 'image', name: 'Image Only' },
{ id: 'both', name: 'Image + Text' }
]
},
{
type: 'button',
label: '',
text: 'Apply Logo',
full: true,
command: (editor) => {
const selected = editor.getSelected();
if (!selected) return;
const attrs = selected.getAttributes();
const mode = attrs.logoMode || 'text';
const text = attrs.logoText || 'SiteName';
const imageUrl = attrs.logoImage || '';
// Clear existing children
selected.components().reset();
if (mode === 'image' && imageUrl) {
selected.components().add({
tagName: 'img',
attributes: { src: imageUrl, alt: text },
style: { height: '40px', width: 'auto' }
});
} else if (mode === 'both' && imageUrl) {
selected.components().add({
tagName: 'img',
attributes: { src: imageUrl, alt: text },
style: { height: '40px', width: 'auto' }
});
selected.components().add({
tagName: 'span',
style: { 'font-size': '20px', 'font-weight': '700', 'color': '#1f2937', 'font-family': 'Inter, sans-serif' },
content: text
});
} else {
// Text mode (default icon + text)
selected.components().add({
tagName: 'div',
style: { width: '40px', height: '40px', background: 'linear-gradient(135deg,#3b82f6 0%,#8b5cf6 100%)', 'border-radius': '8px', display: 'flex', 'align-items': 'center', 'justify-content': 'center' },
components: [{ tagName: 'span', style: { color: '#fff', 'font-weight': '700', 'font-size': '18px', 'font-family': 'Inter,sans-serif' }, content: text.charAt(0).toUpperCase() }]
});
selected.components().add({
tagName: 'span',
style: { 'font-size': '20px', 'font-weight': '700', 'color': '#1f2937', 'font-family': 'Inter, sans-serif' },
content: text
});
}
}
}
]
}
}
});
// ==========================================
// Device Switching
// ==========================================
const deviceButtons = {
desktop: document.getElementById('device-desktop'),
tablet: document.getElementById('device-tablet'),
mobile: document.getElementById('device-mobile')
};
function setDevice(device) {
// Update button states
Object.values(deviceButtons).forEach(btn => btn.classList.remove('active'));
deviceButtons[device].classList.add('active');
// Set device in editor
const deviceMap = {
desktop: 'Desktop',
tablet: 'Tablet',
mobile: 'Mobile'
};
editor.setDevice(deviceMap[device]);
// Force canvas refresh
editor.refresh();
}
deviceButtons.desktop.addEventListener('click', () => setDevice('desktop'));
deviceButtons.tablet.addEventListener('click', () => setDevice('tablet'));
deviceButtons.mobile.addEventListener('click', () => setDevice('mobile'));
// ==========================================
// Undo/Redo
// ==========================================
document.getElementById('btn-undo').addEventListener('click', () => {
editor.UndoManager.undo();
});
document.getElementById('btn-redo').addEventListener('click', () => {
editor.UndoManager.redo();
});
// ==========================================
// Clear Canvas
// ==========================================
document.getElementById('btn-clear').addEventListener('click', () => {
if (confirm('Are you sure you want to clear the canvas and reset the project? This will delete all saved data and cannot be undone.')) {
editor.DomComponents.clear();
editor.CssComposer.clear();
// Clear localStorage to prevent reloading old content
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY + '-preview');
alert('Canvas cleared! Refresh the page for a clean start.');
}
});
// ==========================================
// Preview
// ==========================================
document.getElementById('btn-preview').addEventListener('click', () => {
// Get the HTML and CSS
const html = editor.getHtml();
const css = editor.getCss();
// Store for preview page
localStorage.setItem(STORAGE_KEY + '-preview', JSON.stringify({ html, css }));
// Open preview
window.open('preview.html', '_blank');
});
// ==========================================
// Panel Tabs
// ==========================================
// Left panel tabs (Blocks / Pages / Layers)
document.querySelectorAll('.panel-left .panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
const panel = tab.dataset.panel;
// Update tab states
document.querySelectorAll('.panel-left .panel-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Show/hide panels
document.getElementById('blocks-container').style.display = panel === 'blocks' ? 'block' : 'none';
document.getElementById('pages-container').style.display = panel === 'pages' ? 'block' : 'none';
document.getElementById('layers-container').style.display = panel === 'layers' ? 'block' : 'none';
document.getElementById('assets-container').style.display = panel === 'assets' ? 'block' : 'none';
});
});
// Right panel tabs (Styles / Settings)
document.querySelectorAll('.panel-right .panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
const panel = tab.dataset.panel;
// Update tab states
document.querySelectorAll('.panel-right .panel-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Show/hide panels
document.getElementById('styles-container').style.display = panel === 'styles' ? 'block' : 'none';
document.getElementById('traits-container').style.display = panel === 'traits' ? 'block' : 'none';
const headContainer = document.getElementById('head-elements-container');
if (headContainer) headContainer.style.display = panel === 'head' ? 'block' : 'none';
});
});
// ==========================================
// Style Mode Toggle (Guided / Advanced)
// ==========================================
const modeGuided = document.getElementById('mode-guided');
const modeAdvanced = document.getElementById('mode-advanced');
const guidedStyles = document.getElementById('guided-styles');
const advancedStyles = document.getElementById('advanced-styles');
modeGuided.addEventListener('click', () => {
modeGuided.classList.add('active');
modeAdvanced.classList.remove('active');
guidedStyles.style.display = 'flex';
advancedStyles.style.display = 'none';
});
modeAdvanced.addEventListener('click', () => {
modeAdvanced.classList.add('active');
modeGuided.classList.remove('active');
advancedStyles.style.display = 'block';
guidedStyles.style.display = 'none';
});
// ==========================================
// Context-Aware Guided Style Controls
// ==========================================
// Element type definitions
const ELEMENT_TYPES = {
TEXT: ['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label'],
LINK: ['a'],
DIVIDER: ['hr'],
CONTAINER: ['div', 'section', 'article', 'header', 'footer', 'nav', 'main', 'aside'],
MEDIA: ['img', 'video', 'iframe'],
FORM: ['form', 'input', 'textarea', 'select', 'button']
};
// Get element type category
function getElementType(tagName) {
const tag = tagName?.toLowerCase();
if (ELEMENT_TYPES.TEXT.includes(tag)) return 'text';
if (ELEMENT_TYPES.LINK.includes(tag)) return 'link';
if (ELEMENT_TYPES.DIVIDER.includes(tag)) return 'divider';
if (ELEMENT_TYPES.MEDIA.includes(tag)) return 'media';
if (ELEMENT_TYPES.FORM.includes(tag)) return 'form';
if (ELEMENT_TYPES.CONTAINER.includes(tag)) return 'container';
return 'other';
}
// Check if element is button-like (styled link)
function isButtonLike(component) {
if (!component) return false;
const tagName = component.get('tagName')?.toLowerCase();
if (tagName !== 'a') return false;
const styles = component.getStyle();
// Check if it has button-like styling
return styles['display'] === 'inline-block' ||
styles['padding'] ||
styles['background'] ||
styles['background-color'];
}
// UI Section references
const sections = {
noSelection: document.getElementById('no-selection-msg'),
link: document.getElementById('section-link'),
textColor: document.getElementById('section-text-color'),
headingLevel: document.getElementById('section-heading-level'),
htmlEditorToggle: document.getElementById('section-html-editor-toggle'),
htmlEditor: document.getElementById('section-html-editor'),
bgColor: document.getElementById('section-bg-color'),
bgGradient: document.getElementById('section-bg-gradient'),
bgImage: document.getElementById('section-bg-image'),
overlay: document.getElementById('section-overlay'),
dividerColor: document.getElementById('section-divider-color'),
font: document.getElementById('section-font'),
textSize: document.getElementById('section-text-size'),
fontWeight: document.getElementById('section-font-weight'),
spacing: document.getElementById('section-spacing'),
radius: document.getElementById('section-radius'),
thickness: document.getElementById('section-thickness'),
buttonStyle: document.getElementById('section-button-style'),
navLinks: document.getElementById('section-nav-links')
};
// Link input elements
const linkUrlInput = document.getElementById('link-url-input');
const linkNewTabCheckbox = document.getElementById('link-new-tab');
// Background image elements
const bgImageUrlInput = document.getElementById('bg-image-url');
const bgSizeSelect = document.getElementById('bg-size');
const bgPositionSelect = document.getElementById('bg-position');
const removeBgImageBtn = document.getElementById('remove-bg-image');
// Overlay elements
const overlayOpacitySlider = document.getElementById('overlay-opacity');
const overlayOpacityValue = document.getElementById('overlay-opacity-value');
// Navigation elements
const syncNavPagesBtn = document.getElementById('sync-nav-pages');
const addNavLinkBtn = document.getElementById('add-nav-link');
const navLinksList = document.getElementById('nav-links-list');
// Current overlay state
let currentOverlayColor = '0,0,0';
let currentOverlayOpacity = 50;
// Check if element is an overlay
function isOverlay(component) {
if (!component) return false;
const classes = component.getClasses();
return classes.includes('bg-overlay');
}
// Check if element is a section with background
function isSectionWithBg(component) {
if (!component) return false;
const attrs = component.getAttributes();
return attrs['data-bg-section'] === 'true' || attrs['data-video-section'] === 'true';
}
// Check if element is a navigation
function isNavigation(component) {
if (!component) return false;
const tagName = component.get('tagName')?.toLowerCase();
const classes = component.getClasses();
return tagName === 'nav' || classes.includes('site-navbar');
}
// Hide all context sections
function hideAllSections() {
Object.values(sections).forEach(section => {
if (section) section.style.display = 'none';
});
}
// Show sections based on element type
function showSectionsForElement(component) {
hideAllSections();
if (!component) {
sections.noSelection.style.display = 'block';
return;
}
const tagName = component.get('tagName');
const elementType = getElementType(tagName);
const isButton = isButtonLike(component);
// Check for special element types first
if (isOverlay(component)) {
// Overlay element - show only overlay controls
sections.overlay.style.display = 'block';
loadOverlayValues(component);
return;
}
if (isNavigation(component)) {
// Navigation element - show navigation controls
sections.navLinks.style.display = 'block';
sections.bgColor.style.display = 'block';
sections.spacing.style.display = 'block';
loadNavLinks(component);
return;
}
if (isSectionWithBg(component)) {
// Section with background - show background image controls
sections.bgImage.style.display = 'block';
sections.spacing.style.display = 'block';
loadBgImageValues(component);
return;
}
// Show relevant sections based on element type
switch (elementType) {
case 'text':
sections.textColor.style.display = 'block';
sections.font.style.display = 'block';
sections.textSize.style.display = 'block';
sections.fontWeight.style.display = 'block';
// Show heading level selector for headings
const currentTag = component.get('tagName')?.toLowerCase();
if (currentTag && currentTag.match(/^h[1-6]$/)) {
sections.headingLevel.style.display = 'block';
updateHeadingLevelButtons(currentTag);
}
break;
case 'link':
sections.link.style.display = 'block';
if (isButton) {
sections.buttonStyle.style.display = 'block';
sections.radius.style.display = 'block';
sections.spacing.style.display = 'block';
}
sections.textColor.style.display = 'block';
sections.font.style.display = 'block';
sections.textSize.style.display = 'block';
sections.fontWeight.style.display = 'block';
// Load current link values
loadLinkValues(component);
break;
case 'divider':
sections.dividerColor.style.display = 'block';
sections.thickness.style.display = 'block';
break;
case 'container':
sections.bgColor.style.display = 'block';
sections.bgGradient.style.display = 'block';
sections.bgImage.style.display = 'block';
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
loadBgImageValues(component);
break;
case 'media':
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
break;
case 'form':
if (tagName?.toLowerCase() === 'button') {
sections.buttonStyle.style.display = 'block';
sections.link.style.display = 'block';
}
sections.bgColor.style.display = 'block';
sections.textColor.style.display = 'block';
sections.font.style.display = 'block';
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
break;
default:
// Show common controls for unknown elements
sections.bgColor.style.display = 'block';
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
}
// Always show HTML editor toggle button for any selected element
sections.htmlEditorToggle.style.display = 'block';
}
// Load link values into the input
function loadLinkValues(component) {
if (!component) return;
const attrs = component.getAttributes();
linkUrlInput.value = attrs.href || '';
linkNewTabCheckbox.checked = attrs.target === '_blank';
}
// Update heading level buttons to show active state
function updateHeadingLevelButtons(currentTag) {
const buttons = sections.headingLevel.querySelectorAll('.heading-level-btn');
buttons.forEach(btn => {
const level = btn.getAttribute('data-level');
if (level === currentTag) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// HTML Editor elements
const htmlEditorTextarea = document.getElementById('html-editor-textarea');
const htmlEditorApply = document.getElementById('html-editor-apply');
const htmlEditorCancel = document.getElementById('html-editor-cancel');
const htmlEditorToggleBtn = document.getElementById('html-editor-toggle-btn');
const htmlEditorClose = document.getElementById('html-editor-close');
let originalHtml = '';
let currentEditingComponent = null;
// Load HTML into editor
function loadHtmlEditor(component) {
if (!component) return;
currentEditingComponent = component;
// Get the HTML of the selected component
const html = component.toHTML();
htmlEditorTextarea.value = html;
originalHtml = html;
}
// Show HTML editor
function showHtmlEditor() {
const selected = editor.getSelected();
if (!selected) return;
loadHtmlEditor(selected);
sections.htmlEditor.style.display = 'block';
sections.htmlEditorToggle.style.display = 'none';
}
// Hide HTML editor
function hideHtmlEditor() {
sections.htmlEditor.style.display = 'none';
sections.htmlEditorToggle.style.display = 'block';
currentEditingComponent = null;
}
// Toggle button click
htmlEditorToggleBtn.addEventListener('click', showHtmlEditor);
// Close button click
htmlEditorClose.addEventListener('click', hideHtmlEditor);
// Apply HTML changes
htmlEditorApply.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected) return;
const newHtml = htmlEditorTextarea.value.trim();
try {
// Replace the component with new HTML
const parent = selected.parent();
const index = parent.components().indexOf(selected);
// Remove old component
selected.remove();
// Add new component from HTML
parent.append(newHtml, { at: index });
// Select the new component
const newComponent = parent.components().at(index);
if (newComponent) {
editor.select(newComponent);
}
// Hide editor after applying
hideHtmlEditor();
} catch (error) {
alert('Invalid HTML: ' + error.message);
htmlEditorTextarea.value = originalHtml;
}
});
// Cancel HTML changes
htmlEditorCancel.addEventListener('click', () => {
htmlEditorTextarea.value = originalHtml;
hideHtmlEditor();
});
// Default font sizes for each heading level
const headingSizes = {
h1: '48px',
h2: '36px',
h3: '28px',
h4: '24px',
h5: '20px',
h6: '18px'
};
// Handle heading level button clicks
function setupHeadingLevelButtons() {
const buttons = sections.headingLevel.querySelectorAll('.heading-level-btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const newLevel = btn.getAttribute('data-level');
const selected = editor.getSelected();
if (!selected) return;
// Change the tag name
selected.set('tagName', newLevel);
// Update font size to match heading level
const defaultSize = headingSizes[newLevel];
if (defaultSize) {
selected.addStyle({ 'font-size': defaultSize });
}
// Update button states
updateHeadingLevelButtons(newLevel);
});
});
}
// Load background image values
function loadBgImageValues(component) {
if (!component) return;
const styles = component.getStyle();
const bgImage = styles['background-image'] || '';
const bgSize = styles['background-size'] || 'cover';
const bgPosition = styles['background-position'] || 'center';
// Extract URL from background-image
const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
bgImageUrlInput.value = urlMatch ? urlMatch[1] : '';
bgSizeSelect.value = bgSize;
bgPositionSelect.value = bgPosition;
}
// Load overlay values
function loadOverlayValues(component) {
if (!component) return;
const styles = component.getStyle();
const bg = styles['background'] || '';
// Parse rgba value
const rgbaMatch = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),?\s*([\d.]+)?\)/);
if (rgbaMatch) {
currentOverlayColor = `${rgbaMatch[1]},${rgbaMatch[2]},${rgbaMatch[3]}`;
currentOverlayOpacity = rgbaMatch[4] ? Math.round(parseFloat(rgbaMatch[4]) * 100) : 100;
overlayOpacitySlider.value = currentOverlayOpacity;
overlayOpacityValue.textContent = currentOverlayOpacity + '%';
// Update active color button
document.querySelectorAll('.overlay-color').forEach(btn => {
btn.classList.toggle('active', btn.dataset.color === currentOverlayColor);
});
}
}
// Load navigation links
function loadNavLinks(component) {
if (!component) return;
// Find the nav-links container within the nav
const linksContainer = component.components().find(c => {
const classes = c.getClasses();
return classes.includes('nav-links');
});
if (!linksContainer) {
navLinksList.innerHTML = 'No links container found
';
return;
}
// Get all link components
const links = linksContainer.components().filter(c => c.get('tagName')?.toLowerCase() === 'a');
// Clear and rebuild list
navLinksList.innerHTML = '';
links.forEach((link, index) => {
const item = document.createElement('div');
item.className = 'nav-link-item';
const textSpan = document.createElement('span');
textSpan.className = 'nav-link-text';
textSpan.textContent = link.getEl()?.textContent || 'Link';
const deleteBtn = document.createElement('button');
deleteBtn.className = 'nav-link-delete';
deleteBtn.innerHTML = '';
deleteBtn.title = 'Remove link';
deleteBtn.addEventListener('click', () => {
link.remove();
loadNavLinks(component);
});
item.appendChild(textSpan);
item.appendChild(deleteBtn);
navLinksList.appendChild(item);
});
if (links.length === 0) {
navLinksList.innerHTML = 'No links in navigation
';
}
}
// Helper to apply style to selected component
function applyStyle(property, value) {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({ [property]: value });
}
}
// ==========================================
// Color Preset Handlers
// ==========================================
// Text color presets
document.querySelectorAll('.color-preset.text-color').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('color', btn.dataset.color);
document.querySelectorAll('.text-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Background color presets
document.querySelectorAll('.color-preset.bg-color').forEach(btn => {
btn.addEventListener('click', () => {
const selected = editor.getSelected();
if (selected) {
// Remove gradient when applying solid color
selected.addStyle({
'background-color': btn.dataset.color,
'background-image': 'none',
'background': btn.dataset.color
});
}
document.querySelectorAll('.bg-color').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.gradient-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Gradient presets
document.querySelectorAll('.gradient-preset').forEach(btn => {
btn.addEventListener('click', () => {
const gradient = btn.dataset.gradient;
const selected = editor.getSelected();
if (selected) {
if (gradient === 'none') {
// Remove gradient
selected.addStyle({
'background-image': 'none',
'background': ''
});
} else {
// Apply gradient
selected.addStyle({
'background': gradient,
'background-image': gradient
});
}
}
document.querySelectorAll('.gradient-preset').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.bg-color').forEach(b => b.classList.remove('active'));
if (gradient !== 'none') {
btn.classList.add('active');
}
});
});
// Divider color presets
document.querySelectorAll('.color-preset.divider-color').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('border-top-color', btn.dataset.color);
document.querySelectorAll('.divider-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Button background color presets
document.querySelectorAll('.color-preset.btn-bg-color').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('background-color', btn.dataset.color);
// Also update text color for contrast
const color = btn.dataset.color;
if (color === '#ffffff' || color === '#f9fafb') {
applyStyle('color', '#1f2937');
} else {
applyStyle('color', '#ffffff');
}
document.querySelectorAll('.btn-bg-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ==========================================
// Font Preset Handlers
// ==========================================
document.querySelectorAll('.font-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('font-family', btn.dataset.font);
document.querySelectorAll('.font-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Font weight presets
document.querySelectorAll('.weight-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('font-weight', btn.dataset.weight);
document.querySelectorAll('.weight-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Text size presets
document.querySelectorAll('.size-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('font-size', btn.dataset.size);
document.querySelectorAll('.size-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Spacing presets
document.querySelectorAll('.spacing-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('padding', btn.dataset.padding);
document.querySelectorAll('.spacing-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Border radius presets
document.querySelectorAll('.radius-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('border-radius', btn.dataset.radius);
document.querySelectorAll('.radius-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Line thickness presets (for dividers/hr)
document.querySelectorAll('.thickness-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('border-top-width', btn.dataset.thickness);
document.querySelectorAll('.thickness-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ==========================================
// Heading Level Controls
// ==========================================
setupHeadingLevelButtons();
// ==========================================
// Background Image Controls
// ==========================================
// Update background image URL
const updateBgImage = debounce(() => {
const selected = editor.getSelected();
if (selected) {
const url = bgImageUrlInput.value.trim();
if (url) {
selected.addStyle({ 'background-image': `url(${url})` });
} else {
selected.addStyle({ 'background-image': 'none' });
}
}
}, 300);
bgImageUrlInput.addEventListener('input', updateBgImage);
// Background size
bgSizeSelect.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({ 'background-size': bgSizeSelect.value });
}
});
// Background position
bgPositionSelect.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({ 'background-position': bgPositionSelect.value });
}
});
// Remove background image
removeBgImageBtn.addEventListener('click', () => {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({
'background-image': 'none',
'background-color': '#ffffff'
});
bgImageUrlInput.value = '';
}
});
// ==========================================
// Overlay Controls
// ==========================================
// Overlay color presets
document.querySelectorAll('.overlay-color').forEach(btn => {
btn.addEventListener('click', () => {
currentOverlayColor = btn.dataset.color;
const selected = editor.getSelected();
if (selected) {
const opacity = currentOverlayOpacity / 100;
selected.addStyle({ 'background': `rgba(${currentOverlayColor},${opacity})` });
}
document.querySelectorAll('.overlay-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Overlay opacity slider
overlayOpacitySlider.addEventListener('input', () => {
currentOverlayOpacity = parseInt(overlayOpacitySlider.value);
overlayOpacityValue.textContent = currentOverlayOpacity + '%';
const selected = editor.getSelected();
if (selected && isOverlay(selected)) {
const opacity = currentOverlayOpacity / 100;
selected.addStyle({ 'background': `rgba(${currentOverlayColor},${opacity})` });
}
});
// ==========================================
// Navigation Controls
// ==========================================
// Sync navigation with pages
syncNavPagesBtn.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected || !isNavigation(selected)) return;
// Find the nav-links container
const linksContainer = selected.components().find(c => {
const classes = c.getClasses();
return classes.includes('nav-links');
});
if (!linksContainer) {
alert('Navigation structure not recognized. Please use the Navigation block.');
return;
}
// Get CTA button if exists (keep it)
const existingLinks = linksContainer.components();
let ctaLink = null;
existingLinks.forEach(link => {
const classes = link.getClasses();
if (classes.includes('nav-cta')) {
ctaLink = link.clone();
}
});
// Clear existing links
linksContainer.components().reset();
// Add links for each page
pages.forEach((page, index) => {
linksContainer.components().add({
tagName: 'a',
attributes: { href: page.slug === 'index' ? '#' : `#${page.slug}` },
style: {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: page.name
});
});
// Re-add CTA if existed
if (ctaLink) {
linksContainer.components().add(ctaLink);
}
// Refresh the links list UI
loadNavLinks(selected);
});
// Add new link to navigation
addNavLinkBtn.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected || !isNavigation(selected)) return;
// Find the nav-links container
const linksContainer = selected.components().find(c => {
const classes = c.getClasses();
return classes.includes('nav-links');
});
if (!linksContainer) {
alert('Navigation structure not recognized. Please use the Navigation block.');
return;
}
// Find position to insert (before CTA if exists)
const links = linksContainer.components();
let insertIndex = links.length;
links.forEach((link, index) => {
const classes = link.getClasses();
if (classes.includes('nav-cta')) {
insertIndex = index;
}
});
// Add new link
linksContainer.components().add({
tagName: 'a',
attributes: { href: '#' },
style: {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: 'New Link'
}, { at: insertIndex });
// Refresh the links list UI
loadNavLinks(selected);
});
// ==========================================
// Link Editing
// ==========================================
// Debounce helper
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
// Update link href
const updateLinkHref = debounce(() => {
const selected = editor.getSelected();
if (selected && (selected.get('tagName')?.toLowerCase() === 'a' || selected.get('tagName')?.toLowerCase() === 'button')) {
selected.addAttributes({ href: linkUrlInput.value });
}
}, 300);
linkUrlInput.addEventListener('input', updateLinkHref);
// Update link target
linkNewTabCheckbox.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected && selected.get('tagName')?.toLowerCase() === 'a') {
if (linkNewTabCheckbox.checked) {
selected.addAttributes({ target: '_blank', rel: 'noopener noreferrer' });
} else {
selected.removeAttributes('target');
selected.removeAttributes('rel');
}
}
});
// ==========================================
// Save Status Indicator
// ==========================================
editor.on('storage:start', () => {
saveStatus.classList.add('saving');
saveStatus.classList.remove('saved');
statusText.textContent = 'Saving...';
});
editor.on('storage:end', () => {
saveStatus.classList.remove('saving');
saveStatus.classList.add('saved');
statusText.textContent = 'Saved';
});
editor.on('storage:error', (err) => {
saveStatus.classList.remove('saving');
statusText.textContent = 'Error saving';
console.error('Storage error:', err);
});
// ==========================================
// Keyboard Shortcuts
// ==========================================
document.addEventListener('keydown', (e) => {
// Only handle if not typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Ctrl/Cmd + Z = Undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
editor.UndoManager.undo();
}
// Ctrl/Cmd + Shift + Z = Redo (or Ctrl + Y)
if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
e.preventDefault();
editor.UndoManager.redo();
}
// Delete/Backspace = Remove selected
if (e.key === 'Delete' || e.key === 'Backspace') {
const selected = editor.getSelected();
if (selected && !e.target.isContentEditable) {
e.preventDefault();
selected.remove();
}
}
// Escape = Deselect
if (e.key === 'Escape') {
editor.select(null);
}
});
// ==========================================
// Update guided controls when selection changes
// ==========================================
// Video Section Trait Change Listener
// ==========================================
// Global listener for video URL trait changes
editor.on('component:update', (component) => {
// Check if this is a video section
const attrs = component.getAttributes();
if (attrs && attrs['data-video-section'] === 'true') {
const videoUrl = attrs.videoUrl;
if (videoUrl) {
console.log('Video section updated with URL:', videoUrl);
// Find the bg-video-wrapper child
const videoWrapper = component.components().find(c =>
c.getAttributes()['data-bg-video'] === 'true'
);
if (videoWrapper) {
console.log('Applying video URL to wrapper');
videoWrapper.addAttributes({ videoUrl: videoUrl });
applyVideoUrl(videoWrapper, videoUrl);
} else {
console.error('Video wrapper not found!');
}
}
}
});
// ==========================================
editor.on('component:selected', (component) => {
// Show context-aware UI sections
showSectionsForElement(component);
if (!component) return;
const styles = component.getStyle();
// Reset all active states
document.querySelectorAll('.color-preset, .size-preset, .spacing-preset, .radius-preset, .thickness-preset, .font-preset, .weight-preset, .gradient-preset')
.forEach(btn => btn.classList.remove('active'));
// Set active text color
const textColor = styles['color'];
if (textColor) {
document.querySelectorAll('.text-color').forEach(btn => {
if (btn.dataset.color === textColor) btn.classList.add('active');
});
}
// Set active background color or gradient
const bgColor = styles['background-color'];
const bgImage = styles['background-image'] || styles['background'];
// Check for gradient first
if (bgImage && bgImage.includes('gradient')) {
document.querySelectorAll('.gradient-preset').forEach(btn => {
// Normalize gradient strings for comparison
const btnGradient = btn.dataset.gradient?.replace(/\s/g, '');
const elGradient = bgImage.replace(/\s/g, '');
if (btnGradient && elGradient.includes(btnGradient.replace(/\s/g, ''))) {
btn.classList.add('active');
}
});
} else if (bgColor) {
document.querySelectorAll('.bg-color, .btn-bg-color').forEach(btn => {
if (btn.dataset.color === bgColor) btn.classList.add('active');
});
}
// Set active divider color
const borderTopColor = styles['border-top-color'];
if (borderTopColor) {
document.querySelectorAll('.divider-color').forEach(btn => {
if (btn.dataset.color === borderTopColor) btn.classList.add('active');
});
}
// Set active font family
const fontFamily = styles['font-family'];
if (fontFamily) {
document.querySelectorAll('.font-preset').forEach(btn => {
if (fontFamily.includes(btn.dataset.font.split(',')[0].trim())) {
btn.classList.add('active');
}
});
}
// Set active font weight
const fontWeight = styles['font-weight'];
if (fontWeight) {
document.querySelectorAll('.weight-preset').forEach(btn => {
if (btn.dataset.weight === fontWeight) btn.classList.add('active');
});
}
// Set active font size
const fontSize = styles['font-size'];
if (fontSize) {
document.querySelectorAll('.size-preset').forEach(btn => {
if (btn.dataset.size === fontSize) btn.classList.add('active');
});
}
// Set active padding
const padding = styles['padding'];
if (padding) {
document.querySelectorAll('.spacing-preset').forEach(btn => {
if (btn.dataset.padding === padding) btn.classList.add('active');
});
}
// Set active border radius
const borderRadius = styles['border-radius'];
if (borderRadius) {
document.querySelectorAll('.radius-preset').forEach(btn => {
if (btn.dataset.radius === borderRadius) btn.classList.add('active');
});
}
// Set active thickness
const borderTopWidth = styles['border-top-width'];
if (borderTopWidth) {
document.querySelectorAll('.thickness-preset').forEach(btn => {
if (btn.dataset.thickness === borderTopWidth) btn.classList.add('active');
});
}
});
// Handle deselection
editor.on('component:deselected', () => {
showSectionsForElement(null);
});
// ==========================================
// Context Menu
// ==========================================
const contextMenu = document.getElementById('context-menu');
let clipboard = null; // Store copied component
// Hide context menu
function hideContextMenu() {
contextMenu.classList.remove('visible');
}
// Show context menu at position
function showContextMenu(x, y) {
const selected = editor.getSelected();
if (!selected) return;
// Update disabled states
const pasteItem = contextMenu.querySelector('[data-action="paste"]');
if (pasteItem) {
pasteItem.classList.toggle('disabled', !clipboard);
}
// Position the menu
contextMenu.style.left = x + 'px';
contextMenu.style.top = y + 'px';
// Make sure menu doesn't go off screen
contextMenu.classList.add('visible');
const rect = contextMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
contextMenu.style.left = (x - rect.width) + 'px';
}
if (rect.bottom > window.innerHeight) {
contextMenu.style.top = (y - rect.height) + 'px';
}
}
// Listen for right-click on canvas
editor.on('component:selected', (component) => {
if (!component) return;
// Get the component's element in the canvas
const el = component.getEl();
if (!el) return;
// Remove any existing listener
el.removeEventListener('contextmenu', handleContextMenu);
// Add context menu listener
el.addEventListener('contextmenu', handleContextMenu);
});
function handleContextMenu(e) {
e.preventDefault();
e.stopPropagation();
const selected = editor.getSelected();
if (!selected) return;
// Get canvas iframe offset
const canvas = editor.Canvas;
const canvasEl = canvas.getElement();
const iframe = canvasEl.querySelector('iframe');
const iframeRect = iframe.getBoundingClientRect();
// Calculate position relative to main window
const x = e.clientX + iframeRect.left;
const y = e.clientY + iframeRect.top;
showContextMenu(x, y);
}
// Close context menu when clicking elsewhere
document.addEventListener('click', hideContextMenu);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideContextMenu();
});
// Handle context menu actions
contextMenu.addEventListener('click', (e) => {
const item = e.target.closest('.context-menu-item');
if (!item || item.classList.contains('disabled')) return;
const action = item.dataset.action;
const selected = editor.getSelected();
if (!selected && action !== 'paste') {
hideContextMenu();
return;
}
switch (action) {
case 'edit':
// Trigger inline editing if available
if (selected.get('editable') !== false) {
const el = selected.getEl();
if (el) {
el.setAttribute('contenteditable', 'true');
el.focus();
}
}
break;
case 'duplicate':
const parent = selected.parent();
if (parent) {
const index = parent.components().indexOf(selected);
const clone = selected.clone();
parent.components().add(clone, { at: index + 1 });
editor.select(clone);
}
break;
case 'copy':
clipboard = selected.clone();
break;
case 'paste':
if (clipboard) {
const targetParent = selected.parent() || editor.getWrapper();
if (targetParent) {
const index = selected ? targetParent.components().indexOf(selected) + 1 : undefined;
const pasted = clipboard.clone();
targetParent.components().add(pasted, { at: index });
editor.select(pasted);
}
}
break;
case 'move-up':
const parentUp = selected.parent();
if (parentUp) {
const components = parentUp.components();
const indexUp = components.indexOf(selected);
if (indexUp > 0) {
components.remove(selected);
components.add(selected, { at: indexUp - 1 });
editor.select(selected);
}
}
break;
case 'move-down':
const parentDown = selected.parent();
if (parentDown) {
const components = parentDown.components();
const indexDown = components.indexOf(selected);
if (indexDown < components.length - 1) {
components.remove(selected);
components.add(selected, { at: indexDown + 1 });
editor.select(selected);
}
}
break;
case 'select-parent':
const parentEl = selected.parent();
if (parentEl && parentEl.get('type') !== 'wrapper') {
editor.select(parentEl);
}
break;
case 'wrap':
const wrapParent = selected.parent();
if (wrapParent) {
const wrapIndex = wrapParent.components().indexOf(selected);
// Create wrapper div
const wrapper = wrapParent.components().add({
tagName: 'div',
style: { padding: '20px' },
components: []
}, { at: wrapIndex });
// Move selected into wrapper
selected.move(wrapper, {});
editor.select(wrapper);
}
break;
case 'delete':
selected.remove();
break;
case 'delete-section':
// Find the topmost parent section/container (not wrapper)
{
let sectionTarget = selected;
let sectionParent = sectionTarget.parent();
while (sectionParent && sectionParent.get('type') !== 'wrapper') {
sectionTarget = sectionParent;
sectionParent = sectionTarget.parent();
}
if (confirm('Delete this entire section and all its children?')) {
sectionTarget.remove();
}
}
break;
}
hideContextMenu();
});
// Add keyboard shortcuts for copy/paste/duplicate
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
const selected = editor.getSelected();
if (!selected) return;
// Ctrl/Cmd + C = Copy
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
clipboard = selected.clone();
}
// Ctrl/Cmd + V = Paste
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard) {
e.preventDefault();
const targetParent = selected.parent() || editor.getWrapper();
if (targetParent) {
const index = targetParent.components().indexOf(selected) + 1;
const pasted = clipboard.clone();
targetParent.components().add(pasted, { at: index });
editor.select(pasted);
}
}
// Ctrl/Cmd + D = Duplicate
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault();
const parent = selected.parent();
if (parent) {
const index = parent.components().indexOf(selected);
const clone = selected.clone();
parent.components().add(clone, { at: index + 1 });
editor.select(clone);
}
}
});
// ==========================================
// Add default content if empty
// ==========================================
editor.on('load', () => {
const components = editor.getComponents();
if (components.length === 0) {
// Add a starter section
editor.addComponents(`
Welcome to Site Builder
Drag and drop components from the left panel to build your website. Click on any element to edit its content and style.
Get Started
`);
}
});
// ==========================================
// Anchor Name Input Sync
// ==========================================
// Sync anchor input field with ID attribute
editor.on('component:mount', (component) => {
if (component.get('attributes')?.['data-anchor']) {
setupAnchorInput(component);
}
});
function setupAnchorInput(component) {
const view = component.getEl();
if (!view) return;
const input = view.querySelector('.anchor-name-input');
if (!input) return;
// Sync input value with component ID
const updateInputFromId = () => {
const id = component.getId();
if (input.value !== id) {
input.value = id;
}
};
// Sync component ID with input value
const updateIdFromInput = () => {
const newId = input.value.trim();
if (newId && newId !== component.getId()) {
// Sanitize ID (replace spaces with hyphens, remove special chars)
const sanitizedId = newId
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-_]/g, '');
if (sanitizedId) {
component.setId(sanitizedId);
component.set('attributes', {
...component.get('attributes'),
id: sanitizedId
});
input.value = sanitizedId;
}
}
};
// Prevent GrapesJS from intercepting keyboard events
const stopPropagation = (e) => {
e.stopPropagation();
};
input.addEventListener('keydown', stopPropagation);
input.addEventListener('keyup', stopPropagation);
input.addEventListener('keypress', stopPropagation);
// Prevent component deletion on backspace
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.stopImmediatePropagation();
}
}, true); // Use capture phase
// Initial sync
updateInputFromId();
// Listen for changes
input.addEventListener('input', updateIdFromInput);
input.addEventListener('blur', updateIdFromInput);
// Listen for ID changes from trait manager
component.on('change:attributes:id', updateInputFromId);
}
// ==========================================
// Page Management
// ==========================================
const PAGES_STORAGE_KEY = 'sitebuilder-pages';
const pagesList = document.getElementById('pages-list');
const addPageBtn = document.getElementById('add-page-btn');
const pageModal = document.getElementById('page-modal');
const modalTitle = document.getElementById('modal-title');
const pageNameInput = document.getElementById('page-name');
const pageSlugInput = document.getElementById('page-slug');
const modalSave = document.getElementById('modal-save');
const modalCancel = document.getElementById('modal-cancel');
const modalClose = document.getElementById('modal-close');
const modalDelete = document.getElementById('modal-delete');
let pages = [];
let currentPageId = null;
let editingPageId = null;
// Generate unique ID
function generateId() {
return 'page_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
// Generate slug from name
function slugify(text) {
return text.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load pages from storage
function loadPages() {
const stored = localStorage.getItem(PAGES_STORAGE_KEY);
if (stored) {
try {
pages = JSON.parse(stored);
} catch (e) {
pages = [];
}
}
// Create default home page if no pages exist
if (pages.length === 0) {
pages = [{
id: generateId(),
name: 'Home',
slug: 'index',
html: '',
css: ''
}];
savePages();
}
// Set current page to first page if not set
if (!currentPageId) {
currentPageId = pages[0].id;
}
renderPagesList();
loadPageContent(currentPageId);
}
// Save pages to storage
function savePages() {
localStorage.setItem(PAGES_STORAGE_KEY, JSON.stringify(pages));
}
// Save current page content
function saveCurrentPageContent() {
if (!currentPageId) return;
const page = pages.find(p => p.id === currentPageId);
if (page) {
page.html = editor.getHtml();
page.css = editor.getCss();
savePages();
}
}
// Load page content into editor
function loadPageContent(pageId) {
const page = pages.find(p => p.id === pageId);
if (!page) return;
currentPageId = pageId;
// Clear current content and load page content
editor.DomComponents.clear();
editor.CssComposer.clear();
if (page.html) {
editor.setComponents(page.html);
}
if (page.css) {
editor.setStyle(page.css);
}
renderPagesList();
}
// Switch to a different page
function switchToPage(pageId) {
if (pageId === currentPageId) return;
// Save current page first
saveCurrentPageContent();
// Load new page
loadPageContent(pageId);
}
// Create a single page item element
function createPageItem(page) {
const item = document.createElement('div');
item.className = 'page-item' + (page.id === currentPageId ? ' active' : '');
item.dataset.pageId = page.id;
// Create icon
const icon = document.createElement('div');
icon.className = 'page-item-icon';
icon.innerHTML = '';
// Create info section
const info = document.createElement('div');
info.className = 'page-item-info';
const nameEl = document.createElement('div');
nameEl.className = 'page-item-name';
nameEl.textContent = page.name; // Safe: uses textContent
const slugEl = document.createElement('div');
slugEl.className = 'page-item-slug';
slugEl.textContent = '/' + page.slug; // Safe: uses textContent
info.appendChild(nameEl);
info.appendChild(slugEl);
// Create actions
const actions = document.createElement('div');
actions.className = 'page-item-actions';
// Edit button
const editBtn = document.createElement('button');
editBtn.className = 'page-action-btn';
editBtn.dataset.action = 'edit';
editBtn.title = 'Edit';
editBtn.innerHTML = '';
actions.appendChild(editBtn);
// Delete button (only if more than one page)
if (pages.length > 1) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'page-action-btn danger';
deleteBtn.dataset.action = 'delete';
deleteBtn.title = 'Delete';
deleteBtn.innerHTML = '';
actions.appendChild(deleteBtn);
}
item.appendChild(icon);
item.appendChild(info);
item.appendChild(actions);
// Add click handler for switching pages
item.addEventListener('click', (e) => {
if (e.target.closest('.page-action-btn')) return;
switchToPage(page.id);
});
// Add action button handlers
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
openEditModal(page.id);
});
const deleteBtn = actions.querySelector('[data-action="delete"]');
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deletePage(page.id);
});
}
return item;
}
// Render pages list using safe DOM methods
function renderPagesList() {
pagesList.innerHTML = '';
pages.forEach(page => {
pagesList.appendChild(createPageItem(page));
});
}
// Open modal for new page
function openNewPageModal() {
editingPageId = null;
modalTitle.textContent = 'Add New Page';
pageNameInput.value = '';
pageSlugInput.value = '';
modalDelete.style.display = 'none';
modalSave.textContent = 'Add Page';
pageModal.classList.add('visible');
pageNameInput.focus();
}
// Open modal for editing page
function openEditModal(pageId) {
const page = pages.find(p => p.id === pageId);
if (!page) return;
editingPageId = pageId;
modalTitle.textContent = 'Edit Page';
pageNameInput.value = page.name;
pageSlugInput.value = page.slug;
modalDelete.style.display = pages.length > 1 ? 'inline-block' : 'none';
modalSave.textContent = 'Save Changes';
pageModal.classList.add('visible');
pageNameInput.focus();
}
// Close modal
function closeModal() {
pageModal.classList.remove('visible');
editingPageId = null;
}
// Save page (new or edit)
function savePage() {
const name = pageNameInput.value.trim();
let slug = pageSlugInput.value.trim();
if (!name) {
alert('Please enter a page name');
return;
}
// Generate slug if empty
if (!slug) {
slug = slugify(name);
} else {
slug = slugify(slug);
}
// Ensure slug is unique
const existingPage = pages.find(p => p.slug === slug && p.id !== editingPageId);
if (existingPage) {
slug = slug + '-' + Date.now();
}
if (editingPageId) {
// Update existing page
const page = pages.find(p => p.id === editingPageId);
if (page) {
page.name = name;
page.slug = slug;
}
} else {
// Create new page
const newPage = {
id: generateId(),
name: name,
slug: slug,
html: '',
css: ''
};
pages.push(newPage);
// Save current page before switching
saveCurrentPageContent();
// Switch to new page
currentPageId = newPage.id;
editor.DomComponents.clear();
editor.CssComposer.clear();
}
savePages();
renderPagesList();
closeModal();
}
// Delete page
function deletePage(pageId) {
if (pages.length <= 1) {
alert('Cannot delete the last page');
return;
}
if (!confirm('Are you sure you want to delete this page? This cannot be undone.')) {
return;
}
const index = pages.findIndex(p => p.id === pageId);
if (index > -1) {
pages.splice(index, 1);
savePages();
// If deleting current page, switch to first page
if (pageId === currentPageId) {
currentPageId = pages[0].id;
loadPageContent(currentPageId);
} else {
renderPagesList();
}
}
closeModal();
}
// Auto-save current page on changes
editor.on('storage:end', () => {
saveCurrentPageContent();
});
// Auto-generate slug from name
pageNameInput.addEventListener('input', () => {
if (!editingPageId) {
pageSlugInput.value = slugify(pageNameInput.value);
}
});
// Modal event handlers
addPageBtn.addEventListener('click', openNewPageModal);
modalClose.addEventListener('click', closeModal);
modalCancel.addEventListener('click', closeModal);
modalSave.addEventListener('click', savePage);
modalDelete.addEventListener('click', () => {
if (editingPageId) {
deletePage(editingPageId);
}
});
// Close modal on overlay click
pageModal.addEventListener('click', (e) => {
if (e.target === pageModal) {
closeModal();
}
});
// Close modal on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && pageModal.classList.contains('visible')) {
closeModal();
}
});
// Initialize pages
loadPages();
// ==========================================
// Update Preview to include all pages
// ==========================================
// Override preview button to save all pages data
document.getElementById('btn-preview').addEventListener('click', () => {
// Save current page first
saveCurrentPageContent();
// Store all pages for preview
localStorage.setItem(STORAGE_KEY + '-preview', JSON.stringify({
pages: pages,
currentPageId: currentPageId
}));
// Open preview
window.open('preview.html', '_blank');
});
// Make editor accessible globally for debugging
window.editor = editor;
window.sitePages = pages;
// ==========================================
// Feature: Delete Section (parent + children)
// ==========================================
// Added in context menu handler below
// ==========================================
// Feature: Link Type Selector (URL / Page / Anchor)
// ==========================================
const linkTypeSelect = document.getElementById('link-type-select');
const linkUrlGroup = document.getElementById('link-url-group');
const linkPageGroup = document.getElementById('link-page-group');
const linkAnchorGroup = document.getElementById('link-anchor-group');
const linkPageSelect = document.getElementById('link-page-select');
const linkAnchorSelect = document.getElementById('link-anchor-select');
// Populate page dropdown
function populatePageDropdown() {
linkPageSelect.innerHTML = '';
pages.forEach(page => {
const opt = document.createElement('option');
opt.value = page.slug === 'index' ? '#' : page.slug + '.html';
opt.textContent = page.name;
linkPageSelect.appendChild(opt);
});
}
// Find all anchors on current page
function findAnchors() {
const anchors = [];
function walk(component) {
const id = component.getAttributes().id;
if (id) anchors.push(id);
component.components().forEach(walk);
}
editor.getWrapper().components().forEach(walk);
return anchors;
}
// Populate anchor dropdown
function populateAnchorDropdown() {
linkAnchorSelect.innerHTML = '';
findAnchors().forEach(id => {
const opt = document.createElement('option');
opt.value = '#' + id;
opt.textContent = id;
linkAnchorSelect.appendChild(opt);
});
}
// Handle link type change
if (linkTypeSelect) {
linkTypeSelect.addEventListener('change', () => {
const type = linkTypeSelect.value;
linkUrlGroup.style.display = type === 'url' ? 'block' : 'none';
linkPageGroup.style.display = type === 'page' ? 'block' : 'none';
linkAnchorGroup.style.display = type === 'anchor' ? 'block' : 'none';
if (type === 'page') populatePageDropdown();
if (type === 'anchor') populateAnchorDropdown();
});
linkPageSelect.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected) selected.addAttributes({ href: linkPageSelect.value });
});
linkAnchorSelect.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected && linkAnchorSelect.value) {
selected.addAttributes({ href: linkAnchorSelect.value });
}
});
}
// ==========================================
// Feature: Asset Manager (inline - delegates to server when available)
// ==========================================
const ASSETS_STORAGE_KEY = 'sitebuilder-assets';
const assetUploadBtn = document.getElementById('asset-upload-btn');
const assetUploadInput = document.getElementById('asset-upload-input');
const assetUrlInput = document.getElementById('asset-url-input');
const assetAddUrlBtn = document.getElementById('asset-add-url-btn');
const assetsGrid = document.getElementById('assets-grid');
let assets = [];
let editorServerAvailable = false;
// Check if server API is available
async function checkEditorServer() {
try {
const resp = await fetch('/api/health');
if (resp.ok) {
const data = await resp.json();
editorServerAvailable = data.status === 'ok';
}
} catch (e) {
editorServerAvailable = false;
}
}
async function loadAssets() {
await checkEditorServer();
if (editorServerAvailable) {
try {
const resp = await fetch('/api/assets');
if (resp.ok) {
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
assets = data.assets;
// Save lightweight metadata index to localStorage (no file contents)
saveAssetsMetadata();
renderAssets();
return;
}
}
} catch (e) {
console.warn('Failed to load assets from server:', e.message);
}
}
// Fallback: load from localStorage (metadata only, filter out base64)
try {
assets = JSON.parse(localStorage.getItem(ASSETS_STORAGE_KEY) || '[]');
assets = assets.filter(a => !a.url || !a.url.startsWith('data:'));
} catch(e) { assets = []; }
renderAssets();
}
function saveAssetsMetadata() {
// Save only metadata (no file contents) to localStorage
try {
const metadata = assets.map(a => ({
id: a.id, name: a.name, url: a.url,
type: a.type, size: a.size, added: a.added
}));
localStorage.setItem(ASSETS_STORAGE_KEY, JSON.stringify(metadata));
} catch (e) {
console.warn('Could not cache asset metadata to localStorage:', e.message);
}
}
function saveAssets() {
saveAssetsMetadata();
}
async function uploadFileToServer(file) {
const formData = new FormData();
formData.append('file', file);
const resp = await fetch('/api/assets/upload', { method: 'POST', body: formData });
if (!resp.ok) {
const errData = await resp.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(errData.error || 'Upload failed');
}
const data = await resp.json();
if (data.success && data.assets && data.assets.length > 0) {
return data.assets[0];
}
throw new Error('No asset returned from server');
}
async function deleteAssetFromServer(asset) {
if (asset && asset.url && asset.url.startsWith('/storage/assets/')) {
const filename = asset.id || asset.filename || asset.url.split('/').pop();
try {
await fetch('/api/assets/' + encodeURIComponent(filename), { method: 'DELETE' });
} catch (e) {
console.warn('Server delete failed:', e.message);
}
}
}
function renderAssets() {
if (!assetsGrid) return;
assetsGrid.innerHTML = '';
assets.forEach((asset, i) => {
const item = document.createElement('div');
item.style.cssText = 'position:relative;border-radius:6px;overflow:hidden;border:1px solid #2d2d3a;cursor:pointer;aspect-ratio:1;background:#16161a;display:flex;align-items:center;justify-content:center;';
if (asset.type === 'image') {
const img = document.createElement('img');
img.src = asset.url;
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
img.alt = asset.name;
item.appendChild(img);
} else {
const icon = document.createElement('div');
icon.style.cssText = 'text-align:center;color:#71717a;font-size:11px;padding:8px;';
icon.innerHTML = `${asset.type === 'video' ? '🎬' : '📄'}
${asset.name}
`;
item.appendChild(icon);
}
// Delete button
const del = document.createElement('button');
del.style.cssText = 'position:absolute;top:4px;right:4px;background:rgba(0,0,0,0.7);border:none;color:#fff;width:20px;height:20px;border-radius:50%;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;';
del.textContent = '\u00d7';
del.addEventListener('click', async (e) => {
e.stopPropagation();
if (editorServerAvailable) {
await deleteAssetFromServer(assets[i]);
}
assets.splice(i, 1);
saveAssets();
renderAssets();
});
item.appendChild(del);
// Click to copy URL
item.addEventListener('click', () => {
navigator.clipboard.writeText(asset.url).then(() => {
item.style.outline = '2px solid #3b82f6';
setTimeout(() => item.style.outline = '', 1000);
});
});
assetsGrid.appendChild(item);
});
}
if (assetUploadBtn) {
assetUploadBtn.addEventListener('click', () => assetUploadInput.click());
assetUploadInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
const type = file.type.startsWith('image') ? 'image' : file.type.startsWith('video') ? 'video' : 'file';
if (editorServerAvailable) {
// Upload to server (no base64)
try {
const serverAsset = await uploadFileToServer(file);
assets.push(serverAsset);
saveAssets();
renderAssets();
if (serverAsset.type === 'image') {
editor.AssetManager.add({ src: serverAsset.url, name: serverAsset.name });
}
} catch (err) {
alert('Upload failed: ' + err.message);
}
} else {
// No server available - show error for file uploads
alert('File upload requires server.py to be running.\n\nStart it with: python3 server.py\n\nYou can still add assets by pasting external URLs.');
break;
}
}
assetUploadInput.value = '';
});
}
if (assetAddUrlBtn) {
assetAddUrlBtn.addEventListener('click', () => {
const url = assetUrlInput.value.trim();
if (!url) return;
const name = url.split('/').pop() || 'asset';
const type = url.match(/\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i) ? 'image' :
url.match(/\.(mp4|webm|ogg|mov)(\?.*)?$/i) ? 'video' : 'file';
assets.push({ name, url, type, id: 'asset_' + Date.now(), added: Date.now() });
saveAssets();
renderAssets();
if (type === 'image') editor.AssetManager.add({ src: url, name });
assetUrlInput.value = '';
});
}
loadAssets();
// ==========================================
// Feature: Head Elements & Site-wide CSS
// ==========================================
const HEAD_CODE_KEY = 'sitebuilder-head-code';
const SITEWIDE_CSS_KEY = 'sitebuilder-sitewide-css';
const headCodeTextarea = document.getElementById('head-code-textarea');
const headCodeApply = document.getElementById('head-code-apply');
const sitwideCssTextarea = document.getElementById('sitewide-css-textarea');
const sitewideCssApply = document.getElementById('sitewide-css-apply');
// Load saved head code
if (headCodeTextarea) {
headCodeTextarea.value = localStorage.getItem(HEAD_CODE_KEY) || '';
}
if (sitwideCssTextarea) {
sitwideCssTextarea.value = localStorage.getItem(SITEWIDE_CSS_KEY) || '';
}
if (headCodeApply) {
headCodeApply.addEventListener('click', () => {
localStorage.setItem(HEAD_CODE_KEY, headCodeTextarea.value);
alert('Head code saved! It will be included in exports.');
});
}
if (sitewideCssApply) {
sitewideCssApply.addEventListener('click', () => {
localStorage.setItem(SITEWIDE_CSS_KEY, sitwideCssTextarea.value);
// Apply to canvas immediately
const frame = editor.Canvas.getFrameEl();
if (frame && frame.contentDocument) {
let style = frame.contentDocument.getElementById('sitewide-css');
if (!style) {
style = frame.contentDocument.createElement('style');
style.id = 'sitewide-css';
frame.contentDocument.head.appendChild(style);
}
style.textContent = sitwideCssTextarea.value;
}
alert('Site-wide CSS saved and applied!');
});
}
// ==========================================
// Page HTML Editor Modal
// ==========================================
const pageCodeModal = document.getElementById('page-code-modal');
const pageCodeModalClose = document.getElementById('page-code-modal-close');
const pageCodeTextarea = document.getElementById('page-code-textarea');
const pageCodeApply = document.getElementById('page-code-apply');
const pageCodeCancel = document.getElementById('page-code-cancel');
// Open page HTML editor
document.getElementById('btn-view-code').addEventListener('click', () => {
const html = editor.getHtml();
const css = editor.getCss();
// Show HTML in textarea
pageCodeTextarea.value = html;
pageCodeModal.classList.add('visible');
});
// Close modal
function closePageCodeModal() {
pageCodeModal.classList.remove('visible');
}
pageCodeModalClose.addEventListener('click', closePageCodeModal);
pageCodeCancel.addEventListener('click', closePageCodeModal);
pageCodeModal.addEventListener('click', (e) => {
if (e.target === pageCodeModal) closePageCodeModal();
});
// Apply HTML changes
pageCodeApply.addEventListener('click', () => {
try {
const newHtml = pageCodeTextarea.value.trim();
// Clear current components
editor.DomComponents.clear();
// Set new HTML
editor.setComponents(newHtml);
// Close modal
closePageCodeModal();
// Deselect all
editor.select(null);
} catch (error) {
alert('Invalid HTML: ' + error.message);
}
});
// ==========================================
// Export Functionality
// ==========================================
const exportModal = document.getElementById('export-modal');
const exportModalClose = document.getElementById('export-modal-close');
const exportModalCancel = document.getElementById('export-modal-cancel');
const exportDownloadBtn = document.getElementById('export-download');
const exportPagesList = document.getElementById('export-pages-list');
const exportMinify = document.getElementById('export-minify');
const exportIncludeFonts = document.getElementById('export-include-fonts');
// Open export modal
document.getElementById('btn-export').addEventListener('click', () => {
// Save current page first
saveCurrentPageContent();
// Populate pages list
renderExportPagesList();
exportModal.classList.add('visible');
});
// Close export modal
function closeExportModal() {
exportModal.classList.remove('visible');
}
exportModalClose.addEventListener('click', closeExportModal);
exportModalCancel.addEventListener('click', closeExportModal);
exportModal.addEventListener('click', (e) => {
if (e.target === exportModal) closeExportModal();
});
// Render pages list in export modal
function renderExportPagesList() {
exportPagesList.innerHTML = '';
pages.forEach(page => {
const item = document.createElement('div');
item.className = 'export-page-item';
const info = document.createElement('div');
info.className = 'export-page-info';
const icon = document.createElement('div');
icon.className = 'export-page-icon';
icon.innerHTML = '';
const textDiv = document.createElement('div');
const nameEl = document.createElement('div');
nameEl.className = 'export-page-name';
nameEl.textContent = page.name;
const fileEl = document.createElement('div');
fileEl.className = 'export-page-file';
fileEl.textContent = page.slug + '.html';
textDiv.appendChild(nameEl);
textDiv.appendChild(fileEl);
info.appendChild(icon);
info.appendChild(textDiv);
item.appendChild(info);
exportPagesList.appendChild(item);
});
}
// Generate HTML template for a page
function generatePageHtml(page, includeFonts, minifyCss) {
let css = page.css || '';
let html = page.html || '';
// Remove editor-only anchor elements completely (with nested content)
html = html.replace(/]*data-anchor="true"[^>]*>[\s\S]*?<\/div>/g, '');
html = html.replace(/
]*class="editor-anchor"[^>]*>[\s\S]*?<\/div>/g, '');
// Minify CSS if requested
if (minifyCss && css) {
css = css
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
.replace(/\s+/g, ' ') // Collapse whitespace
.replace(/\s*([{};:,>+~])\s*/g, '$1') // Remove space around punctuation
.trim();
}
// Remove editor-anchor CSS rules from page CSS
css = css.replace(/\.editor-anchor[^}]*}/g, '');
const headCode = localStorage.getItem(HEAD_CODE_KEY) || '';
const sitewideCss = localStorage.getItem(SITEWIDE_CSS_KEY) || '';
const fontsLink = includeFonts
? '
\n
\n
\n '
: '';
return `
${page.name}
${fontsLink}${headCode ? headCode + '\n ' : ''}
Skip to main content
${page.html || ''}
`;
}
// Copy HTML to clipboard (bypasses Windows security warnings)
const exportCopyBtn = document.getElementById('export-copy-html');
exportCopyBtn.addEventListener('click', async () => {
const includeFonts = exportIncludeFonts.checked;
const minifyCss = exportMinify.checked;
// Save current page first
saveCurrentPageContent();
// Get current page
const currentPage = pages.find(p => p.id === currentPageId);
if (!currentPage) {
alert('No page to export!');
return;
}
// Generate HTML
const html = generatePageHtml(currentPage, includeFonts, minifyCss);
// Copy to clipboard
try {
await navigator.clipboard.writeText(html);
// Show success feedback
const originalText = exportCopyBtn.innerHTML;
exportCopyBtn.innerHTML = `
Copied!
`;
exportCopyBtn.style.background = '#10b981';
setTimeout(() => {
exportCopyBtn.innerHTML = originalText;
exportCopyBtn.style.background = '';
}, 2000);
// Show instructions
alert(`✅ HTML copied to clipboard!\n\nNext steps:\n1. Open Notepad (or any text editor)\n2. Paste (Ctrl+V)\n3. Save as "${currentPage.slug}.html"\n4. Open the saved file in your browser\n\nThis bypasses Windows security warnings!`);
} catch (err) {
console.error('Copy failed:', err);
alert('Failed to copy to clipboard. Make sure you\'re using a modern browser with clipboard permissions.');
}
});
// Download as ZIP using JSZip (loaded dynamically)
exportDownloadBtn.addEventListener('click', async () => {
const includeFonts = exportIncludeFonts.checked;
const minifyCss = exportMinify.checked;
// Save current page first
saveCurrentPageContent();
// Check if JSZip is available, if not, load it
if (typeof JSZip === 'undefined') {
// Load JSZip dynamically
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
script.onload = () => createAndDownloadZip(includeFonts, minifyCss);
script.onerror = () => {
// Fallback: download single page if JSZip fails to load
alert('Could not load ZIP library. Downloading current page only.');
downloadSinglePage(pages.find(p => p.id === currentPageId), includeFonts, minifyCss);
};
document.head.appendChild(script);
} else {
createAndDownloadZip(includeFonts, minifyCss);
}
});
// Create ZIP and trigger download
async function createAndDownloadZip(includeFonts, minifyCss) {
const zip = new JSZip();
// Add each page as HTML file
pages.forEach(page => {
const html = generatePageHtml(page, includeFonts, minifyCss);
const filename = page.slug + '.html';
zip.file(filename, html);
});
// Generate and download ZIP
const content = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = url;
a.download = 'site-export.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
closeExportModal();
}
// Fallback: download single page as HTML
function downloadSinglePage(page, includeFonts, minifyCss) {
if (!page) return;
const html = generatePageHtml(page, includeFonts, minifyCss);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = page.slug + '.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
closeExportModal();
}
// ==========================================
// Template System
// ==========================================
let templateIndex = [];
let pendingTemplateId = null;
// Load template index via fetch (requires HTTP server)
async function loadTemplateIndex() {
try {
const resp = await fetch('templates/index.json');
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
templateIndex = await resp.json();
console.log('Loaded', templateIndex.length, 'templates from templates/index.json');
renderTemplateGrid(templateIndex);
} catch (e) {
console.error('Could not load templates:', e);
const grid = document.getElementById('templates-grid');
if (grid) {
grid.innerHTML = `
❌ No templates available
${e.message}
Make sure HTTP server is running
`;
}
}
}
function renderTemplateGrid(templates) {
const grid = document.getElementById('templates-grid');
if (!grid) return;
grid.innerHTML = '';
// Add "Start from Blank" card
const blankCard = document.createElement('div');
blankCard.className = 'template-card';
blankCard.innerHTML = `
Start from Scratch
Begin with a blank page and build your own design.
`;
blankCard.addEventListener('click', () => {
if (confirm('Clear the canvas and start fresh?')) {
editor.DomComponents.clear();
editor.CssComposer.clear();
}
});
grid.appendChild(blankCard);
templates.forEach(t => {
const card = document.createElement('div');
card.className = 'template-card';
card.dataset.category = t.category;
card.innerHTML = `
${t.name}
${t.description}
${t.tags.slice(0, 3).map(tag => `${tag}`).join('')}
${t.colors.map(c => `
`).join('')}
`;
card.addEventListener('click', () => showTemplateConfirm(t));
grid.appendChild(card);
});
}
function showTemplateConfirm(template) {
pendingTemplateId = template.id;
const modal = document.getElementById('template-modal');
document.getElementById('template-modal-title').textContent = template.name;
document.getElementById('template-modal-desc').textContent = template.description + '\n\nUse case: ' + template.useCase;
modal.style.display = 'flex';
}
// Templates browser modal handlers
const templatesBrowserModal = document.getElementById('templates-browser-modal');
const templatesBrowserClose = document.getElementById('templates-browser-close');
const btnTemplates = document.getElementById('btn-templates');
function openTemplatesBrowser() {
if (templatesBrowserModal) {
templatesBrowserModal.style.display = 'flex';
// Reload templates when opened
if (templateIndex.length > 0) {
renderTemplateGrid(templateIndex);
}
}
}
function closeTemplatesBrowser() {
if (templatesBrowserModal) {
templatesBrowserModal.style.display = 'none';
}
}
if (btnTemplates) {
btnTemplates.addEventListener('click', openTemplatesBrowser);
}
if (templatesBrowserClose) {
templatesBrowserClose.addEventListener('click', closeTemplatesBrowser);
}
if (templatesBrowserModal) {
// Close on background click
templatesBrowserModal.addEventListener('click', (e) => {
if (e.target === templatesBrowserModal) {
closeTemplatesBrowser();
}
});
// Close on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && templatesBrowserModal.style.display === 'flex') {
closeTemplatesBrowser();
}
});
}
// Template modal events
const templateModal = document.getElementById('template-modal');
const templateModalClose = document.getElementById('template-modal-close');
const templateModalCancel = document.getElementById('template-modal-cancel');
const templateModalConfirm = document.getElementById('template-modal-confirm');
function closeTemplateModal() {
templateModal.style.display = 'none';
pendingTemplateId = null;
}
if (templateModalClose) templateModalClose.addEventListener('click', closeTemplateModal);
if (templateModalCancel) templateModalCancel.addEventListener('click', closeTemplateModal);
if (templateModal) templateModal.addEventListener('click', (e) => {
if (e.target === templateModal) closeTemplateModal();
});
if (templateModalConfirm) {
templateModalConfirm.addEventListener('click', async () => {
if (!pendingTemplateId) return;
const template = templateIndex.find(t => t.id === pendingTemplateId);
if (!template) return;
try {
templateModalConfirm.textContent = 'Loading...';
templateModalConfirm.disabled = true;
// Load template HTML via fetch
const resp = await fetch('templates/' + template.file);
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
const html = await resp.text();
// Clear canvas and load template HTML
editor.DomComponents.clear();
editor.CssComposer.clear();
editor.setComponents(html);
closeTemplateModal();
closeTemplatesBrowser(); // Also close the templates browser
// Show success notification
const status = document.getElementById('save-status');
if (status) {
const statusText = status.querySelector('.status-text');
const statusDot = status.querySelector('.status-dot');
if (statusText) statusText.textContent = 'Template loaded!';
if (statusDot) statusDot.style.background = '#10b981';
setTimeout(() => {
if (statusText) statusText.textContent = 'Saved';
if (statusDot) statusDot.style.background = '';
}, 2000);
}
} catch (e) {
console.error('Failed to load template:', e);
alert('Failed to load template: ' + e.message + '\n\nPlease check the console for details.');
} finally {
templateModalConfirm.textContent = 'Use Template';
templateModalConfirm.disabled = false;
}
});
}
// Template filter buttons
document.querySelectorAll('.template-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.template-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cat = btn.dataset.category;
if (cat === 'all') {
renderTemplateGrid(templateIndex);
} else {
renderTemplateGrid(templateIndex.filter(t => t.category === cat));
}
});
});
// Load templates on init
loadTemplateIndex();
})();