fix(image-radius): split out 3x-scale IMAGE_RADIUS_PRESETS for the image picker

Image radii need to be visibly larger than the radius scale that works for
buttons/containers — at typical photo dimensions, 16px reads as nearly
square. Add an image-specific scale at 3x the shared values (S=24px,
M=48px, L=96px) and route ImageStylePanel through it. Other components
(buttons, sections, containers) keep RADIUS_PRESETS unchanged.

Note: this commit also bundles unrelated pre-existing working-tree changes
in the legacy GrapesJS site-builder root (CLAUDE.md, index.html,
css/editor.css, js/assets.js, js/editor.js, js/whp-integration.js) that
were inadvertently picked up by an earlier `git add -u`. The image-radius
change is the only intentional content of this commit; the rest is
in-progress legacy work that happened to be sitting uncommitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:31:58 -07:00
parent 8eeaecd857
commit 606c9b78c8
8 changed files with 1591 additions and 364 deletions

View File

@@ -1,5 +1,11 @@
# Site Builder - Project Documentation # Site Builder - Project Documentation
> **LEGACY:** This is the original GrapesJS-based site builder. It has been superseded by the
> Craft.js rebuild located at `/workspace/site-builder/craft/`. All new development happens there.
> This file is preserved as historical reference for the GrapesJS architecture.
>
> The active version: **`/workspace/site-builder/craft/CLAUDE.md`**
## Overview ## Overview
A visual drag-and-drop website builder using GrapesJS. Users can create multi-page websites without writing code, with server-side file storage for assets and localStorage persistence for editor state. A visual drag-and-drop website builder using GrapesJS. Users can create multi-page websites without writing code, with server-side file storage for assets and localStorage persistence for editor state.

View File

@@ -64,6 +64,16 @@ export const RADIUS_PRESETS = [
{ label: 'Full', value: '9999px' }, { label: 'Full', value: '9999px' },
]; ];
// Images need a larger radius scale than buttons/containers to be visually
// distinct on typical photo dimensions.
export const IMAGE_RADIUS_PRESETS = [
{ label: 'None', value: '0' },
{ label: 'S', value: '24px' },
{ label: 'M', value: '48px' },
{ label: 'L', value: '96px' },
{ label: 'Full', value: '9999px' },
];
export const GRADIENTS = [ export const GRADIENTS = [
{ label: 'Purple Dream', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }, { label: 'Purple Dream', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Pink Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' }, { label: 'Pink Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef, CSSProperties } from 'react'; import React, { useState, useCallback, useRef, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
RADIUS_PRESETS, IMAGE_RADIUS_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
@@ -195,7 +195,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Border Radius</SectionLabel> <SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid <PresetButtonGrid
presets={RADIUS_PRESETS} presets={IMAGE_RADIUS_PRESETS}
activeValue={style.borderRadius as string} activeValue={style.borderRadius as string}
onSelect={(v) => setPropStyle('borderRadius', v)} onSelect={(v) => setPropStyle('borderRadius', v)}
/> />

View File

@@ -92,6 +92,26 @@ html, body {
background: #2563eb; background: #2563eb;
} }
.nav-btn.nav-btn-live {
background: #059669;
border-color: #059669;
color: #fff;
}
.nav-btn.nav-btn-live:hover {
background: #047857;
}
.nav-btn.nav-btn-coming-soon {
background: #d97706;
border-color: #d97706;
color: #fff;
}
.nav-btn.nav-btn-coming-soon:hover {
background: #b45309;
}
.divider { .divider {
width: 1px; width: 1px;
height: 24px; height: 24px;
@@ -116,6 +136,7 @@ html, body {
} }
.panel-right { .panel-right {
width: 320px;
border-right: none; border-right: none;
border-left: 1px solid #2d2d3a; border-left: 1px solid #2d2d3a;
} }
@@ -301,6 +322,52 @@ html, body {
} }
/* Size/Spacing/Radius/Thickness Presets */ /* Size/Spacing/Radius/Thickness Presets */
/* Hide GrapesJS default panel buttons and views that conflict with our custom UI */
.gjs-pn-views,
.gjs-pn-views-container,
.gjs-pn-commands {
display: none !important;
}
/* Ensure the GrapesJS editor fills all available width */
.gjs-editor-cont {
width: 100% !important;
}
.gjs-cv-canvas {
width: 100% !important;
left: 0 !important;
}
.alignment-presets {
display: flex;
gap: 4px;
}
.alignment-preset {
flex: 1;
padding: 8px 4px;
background: #2d2d3a;
border: 1px solid #3f3f46;
border-radius: 4px;
color: #a1a1aa;
cursor: pointer;
font-size: 14px;
text-align: center;
transition: all 0.2s;
}
.alignment-preset:hover {
background: #3f3f46;
color: #e4e4e7;
}
.alignment-preset.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.size-presets, .spacing-presets, .radius-presets, .thickness-presets { .size-presets, .spacing-presets, .radius-presets, .thickness-presets {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -1650,6 +1717,7 @@ html, body {
.asset-browser-grid { .asset-browser-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
grid-auto-rows: min-content;
gap: 10px; gap: 10px;
padding: 16px; padding: 16px;
max-height: 400px; max-height: 400px;
@@ -1670,6 +1738,13 @@ html, body {
transition: all 0.15s; transition: all 0.15s;
} }
.asset-browser-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.asset-browser-item:hover { .asset-browser-item:hover {
border-color: #3b82f6; border-color: #3b82f6;
transform: scale(1.02); transform: scale(1.02);

View File

@@ -346,6 +346,57 @@
</div> </div>
</div> </div>
<!-- Image Source -->
<div id="section-image-src" class="guided-section context-section" style="display:none;">
<label>Image</label>
<div class="guided-input-group">
<input type="text" id="image-src-input" class="guided-input" placeholder="Image URL or upload" readonly />
<div style="display:flex;gap:4px;margin-top:6px;">
<button id="image-src-upload" class="modal-btn modal-btn-primary" style="flex:1;padding:6px 10px;font-size:12px;">
<i class="fa fa-upload"></i> Upload
</button>
<button id="image-src-browse" class="modal-btn modal-btn-secondary" style="flex:1;padding:6px 10px;font-size:12px;">
<i class="fa fa-folder-open"></i> Browse
</button>
<button id="image-src-url" class="modal-btn modal-btn-secondary" style="flex:1;padding:6px 10px;font-size:12px;">
<i class="fa fa-link"></i> URL
</button>
</div>
</div>
<div style="margin-top:6px;">
<label style="font-size:11px;">Alt Text</label>
<input type="text" id="image-alt-input" class="guided-input" placeholder="Describe the image" />
</div>
</div>
<!-- Video Source -->
<div id="section-video-src" class="guided-section context-section" style="display:none;">
<label>Video</label>
<div class="guided-input-group">
<input type="text" id="video-src-input" class="guided-input" placeholder="YouTube, Vimeo, or .mp4 URL" />
<div style="display:flex;gap:4px;margin-top:6px;">
<button id="video-src-apply" class="modal-btn modal-btn-primary" style="flex:2;padding:6px 10px;font-size:12px;">
<i class="fa fa-play"></i> Apply Video
</button>
<button id="video-src-browse" class="modal-btn modal-btn-secondary" style="flex:1;padding:6px 10px;font-size:12px;">
<i class="fa fa-folder-open"></i> Browse
</button>
</div>
<div id="video-src-status" style="font-size:11px;color:#71717a;margin-top:4px;"></div>
</div>
</div>
<!-- Text Alignment -->
<div id="section-alignment" class="guided-section context-section" style="display:none;">
<label>Alignment</label>
<div class="alignment-presets">
<button class="alignment-preset" data-align="left" title="Align Left"><i class="fa fa-align-left"></i></button>
<button class="alignment-preset" data-align="center" title="Align Center"><i class="fa fa-align-center"></i></button>
<button class="alignment-preset" data-align="right" title="Align Right"><i class="fa fa-align-right"></i></button>
<button class="alignment-preset" data-align="justify" title="Justify"><i class="fa fa-align-justify"></i></button>
</div>
</div>
<!-- Spacing (for containers) --> <!-- Spacing (for containers) -->
<div id="section-spacing" class="guided-section context-section" style="display:none;"> <div id="section-spacing" class="guided-section context-section" style="display:none;">
<label>Padding</label> <label>Padding</label>
@@ -440,23 +491,23 @@
<!-- Navigation Links --> <!-- Navigation Links -->
<div id="section-nav-links" class="guided-section context-section" style="display:none;"> <div id="section-nav-links" class="guided-section context-section" style="display:none;">
<label>Navigation Links</label> <label>Menu Links</label>
<div class="nav-links-controls"> <div class="nav-links-controls">
<button id="sync-nav-pages" class="guided-btn-primary"> <button id="sync-nav-pages" class="guided-btn-primary" title="Auto-generate links from your site pages">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <i class="fa fa-refresh"></i> Sync Pages
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
Sync with Pages
</button> </button>
<button id="add-nav-link" class="guided-btn-secondary"> <button id="add-nav-link" class="guided-btn-secondary" title="Add a custom link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <i class="fa fa-plus"></i> Page Link
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Link
</button> </button>
<button id="add-nav-external" class="guided-btn-secondary" title="Add an external URL">
<i class="fa fa-external-link"></i> External
</button>
</div>
<div style="margin-top:6px;">
<label class="checkbox-label" style="font-size:11px;color:#a1a1aa;display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="nav-sync-all-pages" checked />
Apply menu to all pages on save
</label>
</div> </div>
<div id="nav-links-list" class="nav-links-list"> <div id="nav-links-list" class="nav-links-list">
<!-- Links will be populated dynamically --> <!-- Links will be populated dynamically -->

View File

@@ -14,6 +14,9 @@
const ASSETS_KEY = 'sitebuilder-assets'; const ASSETS_KEY = 'sitebuilder-assets';
const DEPLOY_STATUS_KEY = 'sitebuilder-deploy-status'; const DEPLOY_STATUS_KEY = 'sitebuilder-deploy-status';
// WHP mode detection
const isWHP = !!window.WHP_CONFIG;
// Server API base URL (same origin) // Server API base URL (same origin)
const API_BASE = ''; const API_BASE = '';
@@ -43,6 +46,12 @@
// -- Server availability check -- // -- Server availability check --
async _checkServer() { async _checkServer() {
if (isWHP) {
// In WHP mode the server is always available (served by WHP)
this.serverAvailable = true;
return;
}
try { try {
const resp = await fetch(API_BASE + '/api/health', { method: 'GET' }); const resp = await fetch(API_BASE + '/api/health', { method: 'GET' });
if (resp.ok) { if (resp.ok) {
@@ -56,6 +65,26 @@
// -- Persistence -- // -- Persistence --
async load() { async load() {
if (isWHP) {
// WHP mode: load assets from WHP API
try {
const resp = await fetch(WHP_CONFIG.apiUrl + '?action=list_assets&site_id=' + WHP_CONFIG.siteId);
if (resp.ok) {
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
this.assets = data.assets;
this.renderAssetsPanel();
return;
}
}
} catch (e) {
console.warn('Failed to load assets from WHP API:', e.message);
}
this.assets = [];
this.renderAssetsPanel();
return;
}
if (this.serverAvailable) { if (this.serverAvailable) {
try { try {
const resp = await fetch(API_BASE + '/api/assets'); const resp = await fetch(API_BASE + '/api/assets');
@@ -168,8 +197,19 @@
formData.append('file', file); formData.append('file', file);
try { try {
const resp = await fetch(API_BASE + '/api/assets/upload', { let uploadUrl;
const headers = {};
if (isWHP) {
uploadUrl = WHP_CONFIG.apiUrl + '?action=upload_asset&site_id=' + WHP_CONFIG.siteId;
headers['X-CSRF-Token'] = WHP_CONFIG.csrfToken;
} else {
uploadUrl = API_BASE + '/api/assets/upload';
}
const resp = await fetch(uploadUrl, {
method: 'POST', method: 'POST',
headers: headers,
body: formData body: formData
}); });
@@ -179,19 +219,32 @@
} }
const data = await resp.json(); const data = await resp.json();
if (data.success && data.assets && data.assets.length > 0) { if (data.success) {
const serverAsset = data.assets[0]; // WHP API returns a single asset object; standalone returns an array
// Add to local list let serverAsset;
this.assets.push(serverAsset); if (data.assets && data.assets.length > 0) {
this.save(); serverAsset = data.assets[0];
this.renderAssetsPanel(); } else if (data.url) {
serverAsset = { url: data.url, name: data.name || file.name, type: data.type || 'file' };
// Register with GrapesJS asset manager
if (serverAsset.type === 'image') {
this.editor.AssetManager.add({ src: serverAsset.url, name: serverAsset.name });
} }
return serverAsset; if (serverAsset) {
// Normalize type for GrapesJS
const mimeType = serverAsset.type || '';
if (mimeType.startsWith('image/') || ['image','jpg','jpeg','png','gif','webp','svg'].includes(mimeType)) {
serverAsset.type = 'image';
}
this.assets.push(serverAsset);
this.save();
this.renderAssetsPanel();
if (serverAsset.type === 'image') {
this.editor.AssetManager.add({ src: serverAsset.url, name: serverAsset.name });
}
return serverAsset;
}
} }
throw new Error('No asset returned from server'); throw new Error('No asset returned from server');
} catch (e) { } catch (e) {
@@ -202,14 +255,22 @@
} }
_showServerRequiredError() { _showServerRequiredError() {
const msg = 'File upload requires the development server (server.py).\n\n' + if (isWHP) {
'Start it with: python3 server.py\n\n' + alert('File upload is temporarily unavailable. Please try again.');
'You can still add assets by pasting external URLs.'; } else {
alert(msg); const msg = 'File upload requires the development server (server.py).\n\n' +
'Start it with: python3 server.py\n\n' +
'You can still add assets by pasting external URLs.';
alert(msg);
}
} }
_showUploadError(message) { _showUploadError(message) {
alert('Asset upload failed: ' + message + '\n\nPlease check that server.py is running.'); if (isWHP) {
alert('Asset upload failed: ' + message);
} else {
alert('Asset upload failed: ' + message + '\n\nPlease check that server.py is running.');
}
} }
addAssetUrl(url) { addAssetUrl(url) {
@@ -234,18 +295,41 @@
async removeAsset(id) { async removeAsset(id) {
const asset = this.assets.find(a => a.id === id); const asset = this.assets.find(a => a.id === id);
// If the asset is stored on the server, delete from server too if (isWHP) {
if (asset && this.serverAvailable && asset.url && asset.url.startsWith('/storage/assets/')) { // WHP mode: delete via WHP API
try { if (asset) {
const filename = asset.id || asset.filename || asset.url.split('/').pop(); try {
const resp = await fetch(API_BASE + '/api/assets/' + encodeURIComponent(filename), { const filename = asset.name || asset.url.split('/').pop();
method: 'DELETE' const resp = await fetch(
}); WHP_CONFIG.apiUrl + '?action=delete_asset&site_id=' + WHP_CONFIG.siteId + '&filename=' + encodeURIComponent(filename),
if (!resp.ok) { {
console.warn('Server-side asset deletion failed'); method: 'DELETE',
headers: {
'X-CSRF-Token': WHP_CONFIG.csrfToken
}
}
);
if (!resp.ok) {
console.warn('WHP asset deletion failed');
}
} catch (e) {
console.warn('Failed to delete asset from WHP:', e.message);
}
}
} else {
// Standalone mode: delete from server if applicable
if (asset && this.serverAvailable && asset.url && asset.url.startsWith('/storage/assets/')) {
try {
const filename = asset.id || asset.filename || asset.url.split('/').pop();
const resp = await fetch(API_BASE + '/api/assets/' + encodeURIComponent(filename), {
method: 'DELETE'
});
if (!resp.ok) {
console.warn('Server-side asset deletion failed');
}
} catch (e) {
console.warn('Failed to delete asset from server:', e.message);
} }
} catch (e) {
console.warn('Failed to delete asset from server:', e.message);
} }
} }
@@ -273,10 +357,11 @@
return; return;
} }
// Group by type // Group by category
const groups = { image: [], video: [], css: [], js: [], file: [] }; const groups = { image: [], video: [], file: [] };
this.assets.forEach(a => { this.assets.forEach(a => {
(groups[a.type] || groups.file).push(a); const cat = this._assetCategory(a);
(groups[cat] || groups.file).push(a);
}); });
// Render images/videos as grid thumbnails // Render images/videos as grid thumbnails
@@ -285,8 +370,8 @@
grid.appendChild(item); grid.appendChild(item);
}); });
// Render CSS/JS as list items // Render other files as list items
[...groups.css, ...groups.js, ...groups.file].forEach(asset => { [...groups.file].forEach(asset => {
const item = this._createListItem(asset); const item = this._createListItem(asset);
grid.appendChild(item); grid.appendChild(item);
}); });
@@ -297,16 +382,17 @@
item.className = 'asset-grid-item'; item.className = 'asset-grid-item';
item.dataset.assetId = asset.id; item.dataset.assetId = asset.id;
if (asset.type === 'image') { if (this._assetCategory(asset) === 'image') {
const img = document.createElement('img'); const img = document.createElement('img');
img.src = asset.url; img.src = asset.url;
img.alt = asset.name; img.alt = asset.name || '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
item.appendChild(img); item.appendChild(img);
} else { } else {
const icon = document.createElement('div'); const icon = document.createElement('div');
icon.className = 'asset-icon'; icon.className = 'asset-icon';
icon.innerHTML = '<div style="font-size:28px;">🎬</div>'; icon.textContent = '🎬';
icon.style.cssText = 'font-size:28px;display:flex;align-items:center;justify-content:center;height:100%;';
item.appendChild(icon); item.appendChild(icon);
} }
@@ -399,6 +485,48 @@
dropZone.style.display = 'none'; dropZone.style.display = 'none';
this._handleFiles(e.dataTransfer.files); this._handleFiles(e.dataTransfer.files);
}); });
// Also allow drag-and-drop onto the editor canvas
this._initCanvasDragDrop();
}
_initCanvasDragDrop() {
const self = this;
// Wait for the GrapesJS canvas iframe to be ready
const checkCanvas = setInterval(() => {
const frame = document.querySelector('.gjs-frame');
if (!frame || !frame.contentDocument) return;
clearInterval(checkCanvas);
const canvasDoc = frame.contentDocument;
canvasDoc.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
canvasDoc.addEventListener('drop', async (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue;
if (self.serverAvailable) {
const asset = await self._uploadFileToServer(file, 'image');
if (asset && asset.url) {
// Insert image at drop position
const selected = self.editor.getSelected();
const parent = selected || self.editor.getWrapper();
parent.append(`<img src="${asset.url}" alt="${asset.name || file.name}" style="max-width:100%;height:auto;" />`);
}
} else {
self._showServerRequiredError();
}
}
});
}, 500);
} }
_handleFiles(files) { _handleFiles(files) {
@@ -504,7 +632,19 @@
if (!this.modal) return Promise.reject('No modal'); if (!this.modal) return Promise.reject('No modal');
// Refresh assets from server to pick up uploads from other code paths // Refresh assets from server to pick up uploads from other code paths
if (this.serverAvailable) { if (isWHP) {
try {
const resp = await fetch(WHP_CONFIG.apiUrl + '?action=list_assets&site_id=' + WHP_CONFIG.siteId);
if (resp.ok) {
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
this.assets = data.assets;
}
}
} catch (e) {
// Use existing cached assets on failure
}
} else if (this.serverAvailable) {
try { try {
const resp = await fetch(API_BASE + '/api/assets'); const resp = await fetch(API_BASE + '/api/assets');
if (resp.ok) { if (resp.ok) {
@@ -541,42 +681,72 @@
} }
} }
// Determine the simple asset category from type/name
_assetCategory(asset) {
const t = (asset.type || '').toLowerCase();
const n = (asset.name || '').toLowerCase();
if (t === 'image' || t.startsWith('image/') || n.match(/\.(jpg|jpeg|png|gif|webp|svg|ico)$/)) return 'image';
if (t === 'video' || t.startsWith('video/') || n.match(/\.(mp4|webm|ogg|mov|avi)$/)) return 'video';
return 'file';
}
renderBrowserGrid(type) { renderBrowserGrid(type) {
const grid = this.modal.querySelector('#asset-browser-grid'); const grid = this.modal.querySelector('#asset-browser-grid');
let items; let items;
if (type === 'all') { if (type === 'all') {
items = this.assets; items = this.assets;
} else if (type === 'video') {
items = this.assets.filter(a => a.type === 'video' || (a.url && a.url.match(/\.(mp4|webm|ogg|mov|avi)$/i)));
} else if (type === 'image') {
items = this.assets.filter(a => a.type === 'image' || (a.url && a.url.match(/\.(jpg|jpeg|png|gif|webp|svg|ico)$/i)));
} else if (type === 'file') {
items = this.assets.filter(a => a.type === 'file' || (a.url && a.url.match(/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|csv)$/i)));
} else { } else {
items = this.assets.filter(a => a.type === type); items = this.assets.filter(a => this._assetCategory(a) === type);
} }
if (items.length === 0) { if (items.length === 0) {
grid.innerHTML = `<div style="grid-column:1/-1;text-align:center;padding:48px 24px;color:#71717a;"> grid.textContent = '';
<div style="font-size:36px;margin-bottom:12px;">📂</div> const emptyZone = this._createDropZone(type);
<div>No ${type === 'all' ? '' : type + ' '}assets yet.</div> grid.appendChild(emptyZone);
<div style="font-size:12px;margin-top:4px;">Upload files above to get started.</div>
</div>`;
return; return;
} }
grid.innerHTML = ''; grid.textContent = '';
// Drop zone hint at top
const dropHint = document.createElement('div');
dropHint.id = 'browser-drop-zone';
dropHint.style.cssText = 'grid-column:1/-1;text-align:center;padding:16px;color:#71717a;border:2px dashed #3f3f46;border-radius:8px;margin:0 0 8px;font-size:12px;transition:border-color 0.2s,background 0.2s;';
dropHint.textContent = 'Drop files here to upload';
grid.appendChild(dropHint);
this._attachDropHandlers(grid);
items.forEach(asset => { items.forEach(asset => {
const cat = this._assetCategory(asset);
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'asset-browser-item'; item.className = 'asset-browser-item';
if (asset.type === 'image') { if (cat === 'image') {
item.innerHTML = `<img src="${asset.url}" alt="${asset.name}" style="width:100%;height:100%;object-fit:cover;">`; const img = document.createElement('img');
} else if (asset.type === 'video') { img.src = asset.url;
item.innerHTML = `<div class="asset-icon"><div style="font-size:36px;">🎬</div></div>`; img.alt = asset.name || '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
img.onerror = function() {
this.replaceWith(Object.assign(document.createElement('div'), {
className: 'asset-icon', textContent: '🖼️',
style: 'font-size:36px;display:flex;align-items:center;justify-content:center;height:100%;'
}));
};
item.appendChild(img);
} else if (cat === 'video') {
const icon = document.createElement('div');
icon.className = 'asset-icon';
icon.style.cssText = 'font-size:36px;display:flex;align-items:center;justify-content:center;height:100%;';
icon.textContent = '🎬';
item.appendChild(icon);
} else { } else {
const icon = asset.type === 'css' ? '🎨' : asset.type === 'js' ? '⚡' : '📄'; const ext = (asset.name || '').split('.').pop().toLowerCase();
item.innerHTML = `<div class="asset-icon"><div style="font-size:36px;">${icon}</div></div>`; const iconChar = ext === 'pdf' ? '📕' : ext === 'css' ? '🎨' : ext === 'js' ? '⚡' : '📄';
const icon = document.createElement('div');
icon.className = 'asset-icon';
icon.style.cssText = 'font-size:36px;display:flex;align-items:center;justify-content:center;height:100%;';
icon.textContent = iconChar;
item.appendChild(icon);
} }
const nameDiv = document.createElement('div'); const nameDiv = document.createElement('div');
@@ -592,6 +762,84 @@
}); });
} }
_createDropZone(type) {
const zone = document.createElement('div');
zone.id = 'browser-drop-zone';
zone.style.cssText = 'grid-column:1/-1;text-align:center;padding:48px 24px;color:#71717a;border:2px dashed #3f3f46;border-radius:8px;margin:16px;cursor:pointer;transition:border-color 0.2s,background 0.2s;';
const iconEl = document.createElement('div');
iconEl.style.cssText = 'font-size:36px;margin-bottom:12px;';
iconEl.textContent = '📁';
zone.appendChild(iconEl);
const msgEl = document.createElement('div');
msgEl.textContent = 'No ' + (type === 'all' ? '' : type + ' ') + 'assets yet.';
zone.appendChild(msgEl);
const hintEl = document.createElement('div');
hintEl.style.cssText = 'font-size:12px;margin-top:4px;';
hintEl.textContent = 'Drag files here or click Upload above.';
zone.appendChild(hintEl);
// Attach drop handlers to the zone's parent after it's added
setTimeout(() => {
const grid = zone.parentElement;
if (grid) this._attachDropHandlers(grid);
}, 0);
return zone;
}
_attachDropHandlers(grid) {
// Avoid attaching multiple times
if (grid._dropAttached) return;
grid._dropAttached = true;
const self = this;
grid.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
const zone = grid.querySelector('#browser-drop-zone');
if (zone) {
zone.style.borderColor = '#3b82f6';
zone.style.background = 'rgba(59,130,246,0.1)';
}
});
grid.addEventListener('dragleave', (e) => {
if (!grid.contains(e.relatedTarget)) {
const zone = grid.querySelector('#browser-drop-zone');
if (zone) {
zone.style.borderColor = '#3f3f46';
zone.style.background = '';
}
}
});
grid.addEventListener('drop', async (e) => {
e.preventDefault();
const zone = grid.querySelector('#browser-drop-zone');
if (zone) {
zone.style.borderColor = '#3f3f46';
zone.style.background = '';
zone.textContent = 'Uploading...';
}
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
const uploads = Array.from(files).map(file => {
const ftype = file.type.startsWith('image/') ? 'image' : file.type.startsWith('video/') ? 'video' : 'file';
if (self.serverAvailable) {
return self._uploadFileToServer(file, ftype);
}
self._showServerRequiredError();
return Promise.resolve(null);
});
await Promise.all(uploads);
const activeTab = self.modal.querySelector('.asset-tab.active');
self.renderBrowserGrid(activeTab?.dataset.type || 'image');
});
}
// -- Patch Image Block Traits -- // -- Patch Image Block Traits --
patchImageTraits() { patchImageTraits() {
const editor = this.editor; const editor = this.editor;
@@ -724,12 +972,97 @@
} }
} }
// -- Deploy Button -- // -- Deploy Button (standalone) / Go Live button (WHP) --
initDeployButton() { initDeployButton() {
const navRight = document.querySelector('.nav-right'); const navRight = document.querySelector('.nav-right');
if (!navRight) return; if (!navRight) return;
// Add deploy button before preview button if (isWHP) {
// In WHP mode: hide Export button and add Go Live / Coming Soon toggle
const exportBtn = document.getElementById('btn-export');
if (exportBtn) exportBtn.style.display = 'none';
const previewBtn = document.getElementById('btn-preview');
const liveBtn = document.createElement('button');
liveBtn.id = 'btn-go-live';
liveBtn.className = 'nav-btn';
liveBtn.title = 'Toggle site visibility';
this._siteIsLive = true; // default: live (files are in docroot)
const updateLiveBtn = (isLive) => {
this._siteIsLive = isLive;
if (isLive) {
liveBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>Coming Soon</span>
`;
liveBtn.title = 'Switch site to Coming Soon mode';
liveBtn.classList.remove('nav-btn-live');
liveBtn.classList.add('nav-btn-coming-soon');
} else {
liveBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
<span>Go Live</span>
`;
liveBtn.title = 'Publish site and make it live';
liveBtn.classList.remove('nav-btn-coming-soon');
liveBtn.classList.add('nav-btn-live');
}
};
// Check current status from API
fetch(WHP_CONFIG.apiUrl + '?action=site_status&site_id=' + WHP_CONFIG.siteId)
.then(r => r.json())
.then(data => {
if (data.success) {
updateLiveBtn(!data.coming_soon);
}
})
.catch(() => updateLiveBtn(true));
liveBtn.addEventListener('click', async () => {
const newIsLive = !this._siteIsLive;
liveBtn.disabled = true;
try {
const resp = await fetch(WHP_CONFIG.apiUrl + '?action=set_site_status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': WHP_CONFIG.csrfToken
},
body: JSON.stringify({
site_id: WHP_CONFIG.siteId,
coming_soon: !newIsLive
})
});
const data = await resp.json();
if (data.success) {
updateLiveBtn(newIsLive);
const msg = newIsLive
? 'Site is now live!'
: 'Site set to Coming Soon mode.';
if (window.whpInt) window.whpInt.showNotification(msg, 'success');
} else {
if (window.whpInt) window.whpInt.showNotification(data.error || 'Failed to update status', 'error');
}
} catch (e) {
if (window.whpInt) window.whpInt.showNotification('Failed to update status: ' + e.message, 'error');
}
liveBtn.disabled = false;
});
navRight.insertBefore(liveBtn, previewBtn);
return; // Skip standalone deploy setup
}
// Standalone mode: add deploy button
const previewBtn = document.getElementById('btn-preview'); const previewBtn = document.getElementById('btn-preview');
const deployBtn = document.createElement('button'); const deployBtn = document.createElement('button');
deployBtn.id = 'btn-deploy'; deployBtn.id = 'btn-deploy';

View File

@@ -185,6 +185,46 @@
// Plugins - using global function references from CDN scripts // Plugins - using global function references from CDN scripts
plugins: [ plugins: [
// Custom plugin to fix image rendering (must be first)
function whpImagePlugin(editor) {
// Override the image component type so real images render
// instead of GrapesJS's SVG placeholder
editor.DomComponents.addType('image', {
model: {
defaults: {
tagName: 'img',
type: 'image',
resizable: { ratioDefault: 1 },
traits: ['alt'],
src: '',
void: true,
},
},
view: {
tagName: 'img',
events: {},
onRender({ el, model }) {
const src = model.getAttributes().src || model.get('src') || '';
if (src) {
el.src = src;
}
// On error: show a subtle visual indicator, NOT the GrapesJS SVG
el.onerror = function() {
this.style.outline = '2px dashed #f59e0b';
this.style.minHeight = '60px';
this.style.minWidth = '100px';
this.style.background = 'rgba(245,158,11,0.1)';
};
el.onload = function() {
this.style.outline = '';
this.style.minHeight = '';
this.style.minWidth = '';
this.style.background = '';
};
},
},
});
},
window['gjs-blocks-basic'], window['gjs-blocks-basic'],
window['grapesjs-preset-webpage'], window['grapesjs-preset-webpage'],
window['grapesjs-plugin-forms'], window['grapesjs-plugin-forms'],
@@ -342,8 +382,8 @@
// Navigation Menu (will be updated dynamically based on pages) // Navigation Menu (will be updated dynamically based on pages)
blockManager.add('navbar', { blockManager.add('navbar', {
label: 'Navigation', label: 'Menu',
category: 'Layout', category: 'Basic',
content: { content: {
type: 'navbar', type: 'navbar',
tagName: 'nav', tagName: 'nav',
@@ -380,7 +420,7 @@
components: [ components: [
{ {
tagName: 'a', tagName: 'a',
attributes: { href: '#' }, attributes: { href: '/' },
style: { style: {
'color': '#4b5563', 'color': '#4b5563',
'text-decoration': 'none', 'text-decoration': 'none',
@@ -391,7 +431,7 @@
}, },
{ {
tagName: 'a', tagName: 'a',
attributes: { href: '#' }, attributes: { href: 'about' },
style: { style: {
'color': '#4b5563', 'color': '#4b5563',
'text-decoration': 'none', 'text-decoration': 'none',
@@ -402,7 +442,7 @@
}, },
{ {
tagName: 'a', tagName: 'a',
attributes: { href: '#' }, attributes: { href: 'contact' },
style: { style: {
'color': '#4b5563', 'color': '#4b5563',
'text-decoration': 'none', 'text-decoration': 'none',
@@ -536,7 +576,7 @@
// Footer // Footer
blockManager.add('footer', { blockManager.add('footer', {
label: 'Footer', label: 'Footer',
category: 'Layout', category: 'Basic',
content: `<footer style="padding:40px 20px;background:#1f2937;color:#9ca3af;text-align:center;"> content: `<footer style="padding:40px 20px;background:#1f2937;color:#9ca3af;text-align:center;">
<div style="max-width:1200px;margin:0 auto;"> <div style="max-width:1200px;margin:0 auto;">
<div style="display:flex;justify-content:center;gap:24px;margin-bottom:20px;"> <div style="display:flex;justify-content:center;gap:24px;margin-bottom:20px;">
@@ -607,7 +647,7 @@
blockManager.add('image-block', { blockManager.add('image-block', {
label: 'Image', label: 'Image',
category: 'Media', category: 'Media',
content: '<img src="https://via.placeholder.com/800x400/3b82f6/ffffff?text=Click+to+change+image" style="max-width:100%;height:auto;display:block;border-radius:8px;" alt="Image">', content: `<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='400' fill='%23e2e8f0'%3E%3Crect width='800' height='400' fill='%23f1f5f9' rx='8'/%3E%3Ctext x='400' y='190' text-anchor='middle' font-family='sans-serif' font-size='24' fill='%2394a3b8'%3EClick to change image%3C/text%3E%3Ctext x='400' y='225' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%23cbd5e1'%3EDrag an image here or select from assets%3C/text%3E%3C/svg%3E" style="max-width:100%;height:auto;display:block;border-radius:8px;" alt="Image">`,
attributes: { class: 'fa fa-image' } attributes: { class: 'fa fa-image' }
}); });
@@ -1828,8 +1868,11 @@
// Store for preview page // Store for preview page
localStorage.setItem(STORAGE_KEY + '-preview', JSON.stringify({ html, css })); localStorage.setItem(STORAGE_KEY + '-preview', JSON.stringify({ html, css }));
// Open preview // Open preview (pass site_id in WHP mode so Back button works)
window.open('preview.html', '_blank'); const previewUrl = (window.WHP_CONFIG)
? 'preview.html?site_id=' + WHP_CONFIG.siteId
: 'preview.html';
window.open(previewUrl, '_blank');
}); });
// ========================================== // ==========================================
@@ -1948,6 +1991,9 @@
font: document.getElementById('section-font'), font: document.getElementById('section-font'),
textSize: document.getElementById('section-text-size'), textSize: document.getElementById('section-text-size'),
fontWeight: document.getElementById('section-font-weight'), fontWeight: document.getElementById('section-font-weight'),
imageSrc: document.getElementById('section-image-src'),
videoSrc: document.getElementById('section-video-src'),
alignment: document.getElementById('section-alignment'),
spacing: document.getElementById('section-spacing'), spacing: document.getElementById('section-spacing'),
radius: document.getElementById('section-radius'), radius: document.getElementById('section-radius'),
thickness: document.getElementById('section-thickness'), thickness: document.getElementById('section-thickness'),
@@ -2041,10 +2087,25 @@
// Section with background - show background image controls // Section with background - show background image controls
sections.bgImage.style.display = 'block'; sections.bgImage.style.display = 'block';
sections.spacing.style.display = 'block'; sections.spacing.style.display = 'block';
// If it's a video section, also show video controls
const attrs = component.getAttributes();
if (attrs['data-video-section'] === 'true') {
sections.videoSrc.style.display = 'block';
loadVideoSrcValues(component);
}
loadBgImageValues(component); loadBgImageValues(component);
return; return;
} }
// Video wrapper (regular video block)
if (component.getAttributes()['data-video-wrapper'] === 'true') {
sections.videoSrc.style.display = 'block';
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
loadVideoSrcValues(component);
return;
}
// Show relevant sections based on element type // Show relevant sections based on element type
switch (elementType) { switch (elementType) {
case 'text': case 'text':
@@ -2052,12 +2113,14 @@
sections.font.style.display = 'block'; sections.font.style.display = 'block';
sections.textSize.style.display = 'block'; sections.textSize.style.display = 'block';
sections.fontWeight.style.display = 'block'; sections.fontWeight.style.display = 'block';
sections.alignment.style.display = 'block';
// Show heading level selector for headings // Show heading level selector for headings
const currentTag = component.get('tagName')?.toLowerCase(); const currentTag = component.get('tagName')?.toLowerCase();
if (currentTag && currentTag.match(/^h[1-6]$/)) { if (currentTag && currentTag.match(/^h[1-6]$/)) {
sections.headingLevel.style.display = 'block'; sections.headingLevel.style.display = 'block';
updateHeadingLevelButtons(currentTag); updateHeadingLevelButtons(currentTag);
} }
updateAlignmentButtons(component);
break; break;
case 'link': case 'link':
@@ -2071,6 +2134,8 @@
sections.font.style.display = 'block'; sections.font.style.display = 'block';
sections.textSize.style.display = 'block'; sections.textSize.style.display = 'block';
sections.fontWeight.style.display = 'block'; sections.fontWeight.style.display = 'block';
sections.alignment.style.display = 'block';
updateAlignmentButtons(component);
// Load current link values // Load current link values
loadLinkValues(component); loadLinkValues(component);
break; break;
@@ -2084,14 +2149,23 @@
sections.bgColor.style.display = 'block'; sections.bgColor.style.display = 'block';
sections.bgGradient.style.display = 'block'; sections.bgGradient.style.display = 'block';
sections.bgImage.style.display = 'block'; sections.bgImage.style.display = 'block';
sections.alignment.style.display = 'block';
sections.spacing.style.display = 'block'; sections.spacing.style.display = 'block';
sections.radius.style.display = 'block'; sections.radius.style.display = 'block';
loadBgImageValues(component); loadBgImageValues(component);
updateAlignmentButtons(component);
break; break;
case 'media': case 'media':
const mediaTag = component.get('tagName')?.toLowerCase();
if (mediaTag === 'img') {
sections.imageSrc.style.display = 'block';
loadImageSrcValues(component);
}
sections.alignment.style.display = 'block';
sections.spacing.style.display = 'block'; sections.spacing.style.display = 'block';
sections.radius.style.display = 'block'; sections.radius.style.display = 'block';
updateAlignmentButtons(component);
break; break;
case 'form': case 'form':
@@ -2296,48 +2370,185 @@
function loadNavLinks(component) { function loadNavLinks(component) {
if (!component) return; if (!component) return;
// Find the nav-links container within the nav const linksContainer = component.components().find(c => c.getClasses().includes('nav-links'));
const linksContainer = component.components().find(c => {
const classes = c.getClasses();
return classes.includes('nav-links');
});
if (!linksContainer) { if (!linksContainer) {
navLinksList.innerHTML = '<p class="no-links-msg">No links container found</p>'; navLinksList.textContent = 'No links container found';
return; return;
} }
// Get all link components
const links = linksContainer.components().filter(c => c.get('tagName')?.toLowerCase() === 'a'); const links = linksContainer.components().filter(c => c.get('tagName')?.toLowerCase() === 'a');
navLinksList.textContent = '';
// Clear and rebuild list if (links.length === 0) {
navLinksList.innerHTML = ''; const msg = document.createElement('p');
msg.className = 'no-links-msg';
msg.textContent = 'No links yet. Use the buttons above to add links.';
navLinksList.appendChild(msg);
return;
}
let dragSrcIndex = null;
links.forEach((link, index) => { links.forEach((link, index) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'nav-link-item'; item.className = 'nav-link-item';
item.draggable = true;
item.dataset.index = index;
item.style.cssText = 'display:flex;flex-direction:column;gap:4px;padding:8px;background:#1e1e2a;border-radius:6px;margin-bottom:4px;border:1px solid transparent;transition:border-color 0.15s;';
const textSpan = document.createElement('span'); const isCta = link.getClasses().includes('nav-cta');
textSpan.className = 'nav-link-text'; const href = link.getAttributes().href || '#';
textSpan.textContent = link.getEl()?.textContent || 'Link'; const text = link.getEl()?.textContent || link.components()?.at(0)?.get('content') || 'Link';
// Drag events for reordering
item.addEventListener('dragstart', (e) => {
dragSrcIndex = index;
item.style.opacity = '0.4';
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => { item.style.opacity = '1'; });
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
item.style.borderColor = '#3b82f6';
});
item.addEventListener('dragleave', () => { item.style.borderColor = 'transparent'; });
item.addEventListener('drop', (e) => {
e.preventDefault();
item.style.borderColor = 'transparent';
if (dragSrcIndex === null || dragSrcIndex === index) return;
// Reorder in GrapesJS model
const movedLink = links[dragSrcIndex];
const targetPos = index;
movedLink.move(linksContainer, { at: targetPos });
dragSrcIndex = null;
loadNavLinks(component);
});
// Row 1: drag handle + text input + delete
const row1 = document.createElement('div');
row1.style.cssText = 'display:flex;align-items:center;gap:4px;';
const dragHandle = document.createElement('span');
dragHandle.style.cssText = 'cursor:grab;color:#71717a;font-size:14px;padding:0 2px;user-select:none;';
dragHandle.textContent = '\u2630'; // hamburger icon ☰
dragHandle.title = 'Drag to reorder';
const textInput = document.createElement('input');
textInput.type = 'text';
textInput.value = text;
textInput.className = 'guided-input';
textInput.style.cssText = 'flex:1;padding:4px 8px;font-size:12px;';
textInput.placeholder = 'Link text';
textInput.addEventListener('change', () => {
const el = link.getEl();
if (el) el.textContent = textInput.value;
link.components(textInput.value);
});
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.className = 'nav-link-delete'; deleteBtn.style.cssText = 'background:none;border:none;color:#ef4444;cursor:pointer;padding:2px 4px;font-size:14px;line-height:1;';
deleteBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'; deleteBtn.textContent = '\u00d7';
deleteBtn.title = 'Remove link'; deleteBtn.title = 'Remove link';
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {
link.remove(); link.remove();
loadNavLinks(component); loadNavLinks(component);
}); });
item.appendChild(textSpan); if (isCta) {
item.appendChild(deleteBtn); const ctaBadge = document.createElement('span');
ctaBadge.style.cssText = 'font-size:9px;background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;white-space:nowrap;';
ctaBadge.textContent = 'CTA';
row1.appendChild(ctaBadge);
}
row1.appendChild(dragHandle);
row1.appendChild(textInput);
row1.appendChild(deleteBtn);
// Row 2: URL selector
const row2 = document.createElement('div');
row2.style.cssText = 'display:flex;align-items:center;gap:4px;padding-left:22px;';
const urlSelect = document.createElement('select');
urlSelect.className = 'guided-input';
urlSelect.style.cssText = 'flex:1;padding:3px 6px;font-size:11px;';
const customOpt = document.createElement('option');
customOpt.value = '__custom__';
customOpt.textContent = 'Custom URL...';
urlSelect.appendChild(customOpt);
const anchorOpt = document.createElement('option');
anchorOpt.value = '__anchor__';
anchorOpt.textContent = 'Anchor on page (#)...';
urlSelect.appendChild(anchorOpt);
pages.forEach(p => {
const opt = document.createElement('option');
opt.value = p.slug === 'index' ? '/' : p.slug;
opt.textContent = p.name + ' (page)';
const matchSlugs = [opt.value, p.slug + '.html', p.slug === 'index' ? 'index.html' : null, p.slug === 'index' ? '#' : null].filter(Boolean);
if (matchSlugs.includes(href)) opt.selected = true;
urlSelect.appendChild(opt);
});
const allPageSlugs = pages.flatMap(p => {
const s = p.slug === 'index' ? '/' : p.slug;
return [s, p.slug + '.html', p.slug === 'index' ? 'index.html' : null, p.slug === 'index' ? '#' : null].filter(Boolean);
});
const isAnchor = href.startsWith('#') && href !== '#';
const isCustom = !isAnchor && !allPageSlugs.includes(href);
const urlInput = document.createElement('input');
urlInput.type = 'text';
urlInput.className = 'guided-input';
urlInput.style.cssText = 'flex:1;padding:3px 8px;font-size:11px;display:' + ((isCustom || isAnchor) ? 'block' : 'none') + ';';
urlInput.placeholder = isAnchor ? '#section-id' : 'https://...';
urlInput.value = (isCustom || isAnchor) ? href : '';
if (isCustom) urlSelect.value = '__custom__';
if (isAnchor) urlSelect.value = '__anchor__';
urlSelect.addEventListener('change', () => {
if (urlSelect.value === '__custom__') {
urlInput.style.display = 'block';
urlInput.placeholder = 'https://...';
urlInput.value = '';
urlInput.focus();
} else if (urlSelect.value === '__anchor__') {
urlInput.style.display = 'block';
urlInput.placeholder = '#section-id';
urlInput.value = '#';
urlInput.focus();
} else {
urlInput.style.display = 'none';
link.addAttributes({ href: urlSelect.value });
// Remove target blank for internal links
link.removeAttributes('target');
link.removeAttributes('rel');
}
});
urlInput.addEventListener('change', () => {
const val = urlInput.value.trim();
if (val) {
link.addAttributes({ href: val });
if (val.startsWith('http://') || val.startsWith('https://')) {
link.addAttributes({ target: '_blank', rel: 'noopener' });
} else {
link.removeAttributes('target');
link.removeAttributes('rel');
}
}
});
row2.appendChild(urlSelect);
row2.appendChild(urlInput);
item.appendChild(row1);
item.appendChild(row2);
navLinksList.appendChild(item); navLinksList.appendChild(item);
}); });
if (links.length === 0) {
navLinksList.innerHTML = '<p class="no-links-msg">No links in navigation</p>';
}
} }
// Helper to apply style to selected component // Helper to apply style to selected component
@@ -2462,6 +2673,246 @@
}); });
}); });
// Alignment presets
document.querySelectorAll('.alignment-preset').forEach(btn => {
btn.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected) return;
const align = btn.dataset.align;
const elementType = getElementType(selected.get('tagName'));
if (elementType === 'container') {
// For containers, set text-align for child content AND flex alignment
applyStyle('text-align', align);
// Also set align-items for flex containers
const currentDisplay = selected.getStyle()['display'];
if (currentDisplay === 'flex' || currentDisplay === 'inline-flex') {
const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end', justify: 'space-between' };
applyStyle('justify-content', justifyMap[align] || align);
applyStyle('align-items', 'center');
}
} else if (elementType === 'media') {
// For images/media, center via parent's text-align or margin auto
const parent = selected.parent();
if (parent) {
parent.addStyle({ 'text-align': align });
}
if (align === 'center') {
applyStyle('margin-left', 'auto');
applyStyle('margin-right', 'auto');
applyStyle('display', 'block');
} else if (align === 'left') {
applyStyle('margin-left', '0');
applyStyle('margin-right', 'auto');
} else if (align === 'right') {
applyStyle('margin-left', 'auto');
applyStyle('margin-right', '0');
}
} else {
// For text elements, set text-align
applyStyle('text-align', align);
}
document.querySelectorAll('.alignment-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Helper to highlight the active alignment button based on current component styles
function updateAlignmentButtons(component) {
if (!component) return;
const styles = component.getStyle();
const currentAlign = styles['text-align'] || '';
document.querySelectorAll('.alignment-preset').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === currentAlign);
});
}
// Image source controls
const imageSrcInput = document.getElementById('image-src-input');
const imageAltInput = document.getElementById('image-alt-input');
const imageSrcUpload = document.getElementById('image-src-upload');
const imageSrcBrowse = document.getElementById('image-src-browse');
const imageSrcUrl = document.getElementById('image-src-url');
function loadImageSrcValues(component) {
if (!component) return;
const attrs = component.getAttributes();
const src = attrs.src || '';
// Show a friendly name instead of the full proxy URL
const filename = src.includes('filename=') ? decodeURIComponent(src.split('filename=').pop().split('&')[0]) : src.split('/').pop();
imageSrcInput.value = filename || 'No image set';
imageAltInput.value = attrs.alt || '';
}
if (imageAltInput) {
imageAltInput.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected && selected.get('tagName')?.toLowerCase() === 'img') {
selected.addAttributes({ alt: imageAltInput.value });
}
});
}
if (imageSrcUpload) {
imageSrcUpload.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
const selected = editor.getSelected();
if (!selected) return;
if (window.assetManager && window.assetManager.serverAvailable) {
const asset = await window.assetManager._uploadFileToServer(file, 'image');
if (asset && asset.url) {
selected.addAttributes({ src: asset.url });
selected.set('src', asset.url);
loadImageSrcValues(selected);
}
}
});
fileInput.click();
});
}
if (imageSrcBrowse) {
imageSrcBrowse.addEventListener('click', async () => {
if (window.assetManager) {
const asset = await window.assetManager.openBrowser('image');
if (asset && asset.url) {
const selected = editor.getSelected();
if (selected) {
selected.addAttributes({ src: asset.url });
selected.set('src', asset.url);
loadImageSrcValues(selected);
}
}
}
});
}
if (imageSrcUrl) {
imageSrcUrl.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected) return;
const current = selected.getAttributes().src || '';
const url = prompt('Enter image URL:', current);
if (url !== null && url.trim()) {
selected.addAttributes({ src: url.trim() });
selected.set('src', url.trim());
loadImageSrcValues(selected);
}
});
}
// Video source controls
const videoSrcInput = document.getElementById('video-src-input');
const videoSrcApply = document.getElementById('video-src-apply');
const videoSrcBrowse = document.getElementById('video-src-browse');
const videoSrcStatus = document.getElementById('video-src-status');
function loadVideoSrcValues(component) {
if (!component) return;
const attrs = component.getAttributes();
const url = attrs.videoUrl || attrs.videourl || '';
if (videoSrcInput) videoSrcInput.value = url;
if (videoSrcStatus) {
if (url) {
const result = convertToEmbedUrl(url);
const typeLabel = result ? (result.type === 'youtube' ? 'YouTube' : result.type === 'vimeo' ? 'Vimeo' : 'Direct video') : 'Unknown';
videoSrcStatus.textContent = typeLabel + ' detected';
} else {
videoSrcStatus.textContent = 'Paste a YouTube, Vimeo, or .mp4 URL';
}
}
}
function applyVideoAndRefreshCanvas(component, url) {
applyVideoUrl(component, url);
// Try to render the video/iframe in the editor canvas
const frame = document.querySelector('.gjs-frame');
if (!frame || !frame.contentDocument) return;
const el = component.getEl();
if (!el) return;
const result = convertToEmbedUrl(url);
if (!result) return;
if (result.type === 'file') {
// Direct video -- show HTML5 video element
let videoEl = el.querySelector('video');
if (!videoEl) {
videoEl = frame.contentDocument.createElement('video');
videoEl.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;';
videoEl.controls = true;
el.appendChild(videoEl);
}
videoEl.src = result.url;
videoEl.style.display = 'block';
// Hide iframe and placeholder
const iframeEl = el.querySelector('iframe');
if (iframeEl) iframeEl.style.display = 'none';
const ph = el.querySelector('.video-placeholder, .bg-video-placeholder');
if (ph) ph.style.display = 'none';
} else {
// YouTube/Vimeo -- show iframe
let iframeEl = el.querySelector('iframe');
if (!iframeEl) {
iframeEl = frame.contentDocument.createElement('iframe');
iframeEl.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;border:none;';
iframeEl.setAttribute('allow', 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
iframeEl.setAttribute('allowfullscreen', '');
el.appendChild(iframeEl);
}
iframeEl.src = result.url;
iframeEl.style.display = 'block';
// Hide video and placeholder
const videoEl = el.querySelector('video');
if (videoEl) videoEl.style.display = 'none';
const ph = el.querySelector('.video-placeholder, .bg-video-placeholder');
if (ph) ph.style.display = 'none';
}
}
if (videoSrcApply) {
videoSrcApply.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected) return;
const url = videoSrcInput.value.trim();
if (!url) return;
selected.addAttributes({ videoUrl: url });
applyVideoAndRefreshCanvas(selected, url);
loadVideoSrcValues(selected);
});
}
if (videoSrcInput) {
videoSrcInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
videoSrcApply?.click();
}
});
}
if (videoSrcBrowse) {
videoSrcBrowse.addEventListener('click', async () => {
if (!window.assetManager) return;
const asset = await window.assetManager.openBrowser('video');
if (asset && asset.url) {
const selected = editor.getSelected();
if (!selected) return;
videoSrcInput.value = asset.url;
selected.addAttributes({ videoUrl: asset.url });
applyVideoAndRefreshCanvas(selected, asset.url);
loadVideoSrcValues(selected);
}
});
}
// Spacing presets // Spacing presets
document.querySelectorAll('.spacing-preset').forEach(btn => { document.querySelectorAll('.spacing-preset').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -2577,102 +3028,92 @@
// ========================================== // ==========================================
// Sync navigation with pages // Sync navigation with pages
// Helper: find nav-links container and CTA insert position
function getNavLinksContainer(navComponent) {
const linksContainer = navComponent.components().find(c => c.getClasses().includes('nav-links'));
if (!linksContainer) return null;
const links = linksContainer.components();
let insertIndex = links.length;
links.forEach((link, index) => {
if (link.getClasses().includes('nav-cta')) insertIndex = index;
});
return { container: linksContainer, insertIndex };
}
const defaultLinkStyle = {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
};
// Sync navigation with pages -- creates links to actual page files
syncNavPagesBtn.addEventListener('click', () => { syncNavPagesBtn.addEventListener('click', () => {
const selected = editor.getSelected(); const selected = editor.getSelected();
if (!selected || !isNavigation(selected)) return; if (!selected || !isNavigation(selected)) return;
const nav = getNavLinksContainer(selected);
if (!nav) { alert('Navigation structure not recognized.'); return; }
// Find the nav-links container // Preserve CTA
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; let ctaLink = null;
existingLinks.forEach(link => { nav.container.components().forEach(link => {
const classes = link.getClasses(); if (link.getClasses().includes('nav-cta')) ctaLink = link.clone();
if (classes.includes('nav-cta')) {
ctaLink = link.clone();
}
}); });
// Clear existing links nav.container.components().reset();
linksContainer.components().reset();
// Add links for each page // Add a link for each page using clean slugs (no .html)
pages.forEach((page, index) => { pages.forEach(page => {
linksContainer.components().add({ const href = page.slug === 'index' ? '/' : page.slug;
nav.container.components().add({
tagName: 'a', tagName: 'a',
attributes: { href: page.slug === 'index' ? '#' : `#${page.slug}` }, attributes: { href },
style: { style: { ...defaultLinkStyle },
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: page.name content: page.name
}); });
}); });
// Re-add CTA if existed if (ctaLink) nav.container.components().add(ctaLink);
if (ctaLink) {
linksContainer.components().add(ctaLink);
}
// Refresh the links list UI
loadNavLinks(selected); loadNavLinks(selected);
}); });
// Add new link to navigation // Add page link
addNavLinkBtn.addEventListener('click', () => { addNavLinkBtn.addEventListener('click', () => {
const selected = editor.getSelected(); const selected = editor.getSelected();
if (!selected || !isNavigation(selected)) return; if (!selected || !isNavigation(selected)) return;
const nav = getNavLinksContainer(selected);
if (!nav) return;
// Find the nav-links container nav.container.components().add({
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', tagName: 'a',
attributes: { href: '#' }, attributes: { href: '/' },
style: { style: { ...defaultLinkStyle },
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: 'New Link' content: 'New Link'
}, { at: insertIndex }); }, { at: nav.insertIndex });
// Refresh the links list UI
loadNavLinks(selected); loadNavLinks(selected);
}); });
// Add external link
const addNavExternalBtn = document.getElementById('add-nav-external');
if (addNavExternalBtn) {
addNavExternalBtn.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected || !isNavigation(selected)) return;
const nav = getNavLinksContainer(selected);
if (!nav) return;
nav.container.components().add({
tagName: 'a',
attributes: { href: 'https://', target: '_blank', rel: 'noopener' },
style: { ...defaultLinkStyle },
content: 'External Link'
}, { at: nav.insertIndex });
loadNavLinks(selected);
});
}
// ========================================== // ==========================================
// Link Editing // Link Editing
// ========================================== // ==========================================
@@ -2808,7 +3249,7 @@
const styles = component.getStyle(); const styles = component.getStyle();
// Reset all active states // Reset all active states
document.querySelectorAll('.color-preset, .size-preset, .spacing-preset, .radius-preset, .thickness-preset, .font-preset, .weight-preset, .gradient-preset') document.querySelectorAll('.color-preset, .size-preset, .spacing-preset, .radius-preset, .thickness-preset, .font-preset, .weight-preset, .gradient-preset, .alignment-preset')
.forEach(btn => btn.classList.remove('active')); .forEach(btn => btn.classList.remove('active'));
// Set active text color // Set active text color
@@ -3611,14 +4052,50 @@
currentPageId: currentPageId currentPageId: currentPageId
})); }));
// Open preview // Open preview (pass site_id in WHP mode so Back button works)
window.open('preview.html', '_blank'); const previewUrl = (window.WHP_CONFIG)
? 'preview.html?site_id=' + WHP_CONFIG.siteId
: 'preview.html';
window.open(previewUrl, '_blank');
}); });
// Make editor accessible globally for debugging // Make editor accessible globally for debugging
window.editor = editor; window.editor = editor;
window.sitePages = pages; window.sitePages = pages;
// ==========================================
// WHP Mode: Asset Manager integration
// ==========================================
const isWHP = !!window.WHP_CONFIG;
if (isWHP) {
// Override asset manager upload URL to use WHP API
editor.AssetManager.getConfig().upload = WHP_CONFIG.apiUrl + '?action=upload_asset&site_id=' + WHP_CONFIG.siteId;
editor.AssetManager.getConfig().uploadName = 'file';
// Custom headers for CSRF protection
editor.AssetManager.getConfig().headers = {
'X-CSRF-Token': WHP_CONFIG.csrfToken
};
// Load existing assets from WHP API
fetch(WHP_CONFIG.apiUrl + '?action=list_assets&site_id=' + WHP_CONFIG.siteId)
.then(r => r.json())
.then(data => {
if (data.success && data.assets) {
data.assets.forEach(asset => {
const isImage = asset.type && (asset.type === 'image' || asset.type.startsWith('image/'));
editor.AssetManager.add({
src: asset.url,
name: asset.name,
type: isImage ? 'image' : 'file'
});
});
}
})
.catch(e => console.log('Failed to load assets:', e));
}
// ========================================== // ==========================================
// Feature: Delete Section (parent + children) // Feature: Delete Section (parent + children)
// ========================================== // ==========================================

View File

@@ -1,31 +1,56 @@
/** /**
* WHP Integration for Site Builder * WHP Integration for Site Builder
* Provides save/load functionality via WHP API * Provides save/load functionality via WHP API when running inside
* the Web Hosting Panel, or standalone fallbacks otherwise.
*
* WHP mode is detected by the presence of window.WHP_CONFIG, which is
* injected by the PHP wrapper page.
*/ */
const isWHP = !!window.WHP_CONFIG;
class WHPIntegration { class WHPIntegration {
constructor(editor, apiUrl = '/api/site-builder.php') { constructor(editor, apiUrl) {
this.editor = editor; this.editor = editor;
this.apiUrl = apiUrl;
this.currentSiteId = null; if (isWHP) {
this.currentSiteName = 'Untitled Site'; this.apiUrl = WHP_CONFIG.apiUrl;
this.currentSiteId = WHP_CONFIG.siteId;
this.currentSiteName = WHP_CONFIG.siteName || WHP_CONFIG.siteDomain || 'Site';
} else {
this.apiUrl = apiUrl || '/api/site-builder.php';
this.currentSiteId = null;
this.currentSiteName = 'Untitled Site';
}
this.init(); this.init();
} }
init() { init() {
// Add save/load buttons to the editor
this.addToolbarButtons(); this.addToolbarButtons();
if (isWHP) {
// In WHP mode, add the "Back to Panel" link and site domain badge
this.addBackButton();
this.addSiteDomainBadge();
}
// Auto-save every 30 seconds // Auto-save every 30 seconds
setInterval(() => this.autoSave(), 30000); setInterval(() => this.autoSave(), 30000);
// Load site list on startup // In standalone mode, load the site list on startup.
this.loadSiteList(); // In WHP mode we always work on one site so no list is needed.
if (!isWHP) {
this.loadSiteList();
}
} }
// -------------------------------------------------
// Toolbar buttons
// -------------------------------------------------
addToolbarButtons() { addToolbarButtons() {
// Add "Save to WHP" button // Add "Save" button
const saveBtn = document.createElement('button'); const saveBtn = document.createElement('button');
saveBtn.id = 'btn-whp-save'; saveBtn.id = 'btn-whp-save';
saveBtn.className = 'nav-btn primary'; saveBtn.className = 'nav-btn primary';
@@ -37,79 +62,227 @@ class WHPIntegration {
</svg> </svg>
<span>Save</span> <span>Save</span>
`; `;
saveBtn.onclick = () => this.showSaveDialog();
// Add "Load from WHP" button if (isWHP) {
const loadBtn = document.createElement('button'); // In WHP mode, save immediately (site name comes from WHP)
loadBtn.id = 'btn-whp-load'; saveBtn.onclick = () => this.saveToWHP();
loadBtn.className = 'nav-btn'; } else {
loadBtn.innerHTML = ` // Standalone: prompt for a name
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> saveBtn.onclick = () => this.showSaveDialog();
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path> }
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span>Load</span>
`;
loadBtn.onclick = () => this.showLoadDialog();
// Insert buttons before export button if (isWHP) {
const exportBtn = document.getElementById('btn-export'); // In WHP mode we only show the Save button (no Load button)
if (exportBtn && exportBtn.parentNode) { const exportBtn = document.getElementById('btn-export');
exportBtn.parentNode.insertBefore(loadBtn, exportBtn); if (exportBtn && exportBtn.parentNode) {
exportBtn.parentNode.insertBefore(saveBtn, exportBtn); exportBtn.parentNode.insertBefore(saveBtn, exportBtn);
// Add divider const divider = document.createElement('span');
const divider = document.createElement('span'); divider.className = 'divider';
divider.className = 'divider'; exportBtn.parentNode.insertBefore(divider, exportBtn);
exportBtn.parentNode.insertBefore(divider, exportBtn); }
} else {
// Standalone: show both Save and Load
const loadBtn = document.createElement('button');
loadBtn.id = 'btn-whp-load';
loadBtn.className = 'nav-btn';
loadBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span>Load</span>
`;
loadBtn.onclick = () => this.showLoadDialog();
const exportBtn = document.getElementById('btn-export');
if (exportBtn && exportBtn.parentNode) {
exportBtn.parentNode.insertBefore(loadBtn, exportBtn);
exportBtn.parentNode.insertBefore(saveBtn, exportBtn);
const divider = document.createElement('span');
divider.className = 'divider';
exportBtn.parentNode.insertBefore(divider, exportBtn);
}
} }
} }
// -------------------------------------------------
// WHP-only: "Back to Panel" button
// -------------------------------------------------
addBackButton() {
const backBtn = document.createElement('a');
backBtn.href = WHP_CONFIG.backUrl;
backBtn.className = 'nav-btn';
backBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
<span>Back to Panel</span>
`;
const nav = document.querySelector('.nav-left') || document.querySelector('.editor-nav');
if (nav) {
nav.insertBefore(backBtn, nav.firstChild);
}
}
// -------------------------------------------------
// WHP-only: show site domain in the nav bar
// -------------------------------------------------
addSiteDomainBadge() {
const badge = document.createElement('span');
badge.className = 'nav-site-domain';
badge.style.cssText = 'display:inline-flex;align-items:center;padding:4px 10px;margin-left:8px;font-size:13px;color:#a1a1aa;border:1px solid #3f3f46;border-radius:4px;user-select:all;';
badge.textContent = WHP_CONFIG.siteDomain;
const nav = document.querySelector('.nav-left') || document.querySelector('.editor-nav');
if (nav) {
nav.appendChild(badge);
}
}
// -------------------------------------------------
// Save
// -------------------------------------------------
async saveToWHP(siteId = null, siteName = null) { async saveToWHP(siteId = null, siteName = null) {
const html = this.editor.getHtml(); const html = this.editor.getHtml();
const css = this.editor.getCss(); const css = this.editor.getCss();
const grapesjs = this.editor.getProjectData(); const grapesjs = this.editor.getProjectData();
const data = { if (isWHP) {
id: siteId || this.currentSiteId || 'site_' + Date.now(), // ---- WHP mode ----
name: siteName || this.currentSiteName, // Save current page content first
html: html, if (typeof saveCurrentPageContent === 'function') {
css: css, saveCurrentPageContent();
grapesjs: grapesjs,
modified: Math.floor(Date.now() / 1000),
created: this.currentSiteId ? undefined : Math.floor(Date.now() / 1000)
};
// Try WHP API first, fall back to localStorage
try {
const response = await fetch(`${this.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error('API returned non-JSON response');
} }
const result = await response.json(); // Build pages array from sitePages (multi-page support)
// Files are saved as .html on disk; .htaccess handles clean URLs
const allPages = window.sitePages || [];
const pagesData = allPages.map(page => ({
filename: page.slug === 'index' ? 'index.html' : page.slug + '.html',
slug: page.slug,
title: page.name || 'Page',
html: page.html || '',
css: page.css || ''
}));
if (result.success) { // If no pages tracked, fall back to single-page mode
this.currentSiteId = result.site.id; if (pagesData.length === 0) {
this.currentSiteName = result.site.name; pagesData.push({
this.showNotification(`Saved "${result.site.name}" successfully!`, 'success'); filename: 'index.html',
return result.site; title: WHP_CONFIG.siteName || 'Home',
} else { html: html,
throw new Error(result.error || 'Save failed'); css: css
});
}
// Sync navigation across pages if checkbox is checked
const syncNavCheckbox = document.getElementById('nav-sync-all-pages');
if (syncNavCheckbox && syncNavCheckbox.checked && pagesData.length > 1) {
// Find nav HTML in the current page
const navMatch = html.match(/<nav[^>]*class="[^"]*site-navbar[^"]*"[^>]*>[\s\S]*?<\/nav>/i);
if (navMatch) {
const navHtml = navMatch[0];
pagesData.forEach(page => {
if (page.html) {
// Replace existing nav or prepend it
const hasNav = /<nav[^>]*class="[^"]*site-navbar[^"]*"[^>]*>[\s\S]*?<\/nav>/i.test(page.html);
if (hasNav) {
page.html = page.html.replace(/<nav[^>]*class="[^"]*site-navbar[^"]*"[^>]*>[\s\S]*?<\/nav>/i, navHtml);
}
}
});
}
}
const data = {
site_id: WHP_CONFIG.siteId,
name: WHP_CONFIG.siteName,
html: html,
css: css,
pages: pagesData,
grapesjs: grapesjs,
modified: Math.floor(Date.now() / 1000)
};
try {
const response = await fetch(`${this.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': WHP_CONFIG.csrfToken
},
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error('API returned non-JSON response');
}
const result = await response.json();
if (result.success) {
this.showNotification('Saved successfully!', 'success');
return result;
} else {
throw new Error(result.error || 'Save failed');
}
} catch (error) {
console.error('WHP save failed:', error.message);
this.showNotification('Save failed: ' + error.message, 'error');
return null;
}
} else {
// ---- Standalone mode (original behaviour) ----
const data = {
id: siteId || this.currentSiteId || 'site_' + Date.now(),
name: siteName || this.currentSiteName,
html: html,
css: css,
grapesjs: grapesjs,
modified: Math.floor(Date.now() / 1000),
created: this.currentSiteId ? undefined : Math.floor(Date.now() / 1000)
};
try {
const response = await fetch(`${this.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error('API returned non-JSON response');
}
const result = await response.json();
if (result.success) {
this.currentSiteId = result.site.id;
this.currentSiteName = result.site.name;
this.showNotification(`Saved "${result.site.name}" successfully!`, 'success');
return result.site;
} else {
throw new Error(result.error || 'Save failed');
}
} catch (error) {
console.log('WHP API not available, using localStorage fallback:', error.message);
return this._saveToLocalStorage(data);
} }
} catch (error) {
console.log('WHP API not available, using localStorage fallback:', error.message);
return this._saveToLocalStorage(data);
} }
} }
@@ -136,11 +309,9 @@ class WHPIntegration {
// Fall back to localStorage with size check // Fall back to localStorage with size check
try { try {
// Load existing sites list
const sitesJson = localStorage.getItem('whp-sites') || '[]'; const sitesJson = localStorage.getItem('whp-sites') || '[]';
const sites = JSON.parse(sitesJson); const sites = JSON.parse(sitesJson);
// Update or add
const idx = sites.findIndex(s => s.id === data.id); const idx = sites.findIndex(s => s.id === data.id);
if (idx >= 0) { if (idx >= 0) {
sites[idx] = data; sites[idx] = data;
@@ -164,22 +335,47 @@ class WHPIntegration {
} }
} }
async loadFromWHP(siteId) { // -------------------------------------------------
try { // Load
const response = await fetch(`${this.apiUrl}?action=load&id=${encodeURIComponent(siteId)}`); // -------------------------------------------------
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
const result = await response.json();
if (result.success) { async loadFromWHP(siteId) {
this._applySiteData(result.site); if (isWHP) {
} else { // ---- WHP mode ----
throw new Error(result.error || 'Load failed'); try {
const response = await fetch(`${this.apiUrl}?action=load&site_id=${encodeURIComponent(WHP_CONFIG.siteId)}`);
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
const result = await response.json();
if (result.success) {
this._applySiteData(result.site || result);
} else {
throw new Error(result.error || 'Load failed');
}
} catch (error) {
console.error('WHP load failed:', error.message);
this.showNotification('Load failed: ' + error.message, 'error');
}
} else {
// ---- Standalone mode ----
try {
const response = await fetch(`${this.apiUrl}?action=load&id=${encodeURIComponent(siteId)}`);
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
const result = await response.json();
if (result.success) {
this._applySiteData(result.site);
} else {
throw new Error(result.error || 'Load failed');
}
} catch (error) {
console.log('WHP API not available, loading from localStorage:', error.message);
this._loadFromLocalStorage(siteId);
} }
} catch (error) {
console.log('WHP API not available, loading from localStorage:', error.message);
this._loadFromLocalStorage(siteId);
} }
} }
@@ -190,9 +386,9 @@ class WHPIntegration {
this.editor.setComponents(site.html || ''); this.editor.setComponents(site.html || '');
this.editor.setStyle(site.css || ''); this.editor.setStyle(site.css || '');
} }
this.currentSiteId = site.id; this.currentSiteId = site.id || this.currentSiteId;
this.currentSiteName = site.name; this.currentSiteName = site.name || this.currentSiteName;
this.showNotification(`Loaded "${site.name}" successfully!`, 'success'); this.showNotification(`Loaded "${this.currentSiteName}" successfully!`, 'success');
} }
async _loadFromLocalStorage(siteId) { async _loadFromLocalStorage(siteId) {
@@ -224,85 +420,147 @@ class WHPIntegration {
} }
} }
async loadSiteList() { // -------------------------------------------------
try { // Site list
const response = await fetch(`${this.apiUrl}?action=list`); // -------------------------------------------------
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
const result = await response.json();
if (result.success) { async loadSiteList() {
return result.sites; if (isWHP) {
} else { // ---- WHP mode ----
throw new Error(result.error || 'Failed to load site list');
}
} catch (error) {
console.log('WHP API not available, trying server project list:', error.message);
// Try server-side project storage
try { try {
const resp = await fetch('/api/projects/list'); const response = await fetch(`${this.apiUrl}?action=list`);
if (resp.ok) { if (!response.ok) throw new Error('API returned ' + response.status);
const result = await resp.json(); const contentType = response.headers.get('content-type') || '';
if (result.success && Array.isArray(result.projects)) { if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
return result.projects; const result = await response.json();
}
if (result.success) {
return result.sites;
} else {
throw new Error(result.error || 'Failed to load site list');
} }
} catch (e) { } catch (error) {
console.log('Server project list not available, using localStorage:', e.message); console.error('WHP loadSiteList failed:', error.message);
}
// Final fallback: localStorage
try {
return JSON.parse(localStorage.getItem('whp-sites') || '[]');
} catch (e) {
return []; return [];
} }
} else {
// ---- Standalone mode ----
try {
const response = await fetch(`${this.apiUrl}?action=list`);
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
const result = await response.json();
if (result.success) {
return result.sites;
} else {
throw new Error(result.error || 'Failed to load site list');
}
} catch (error) {
console.log('WHP API not available, trying server project list:', error.message);
try {
const resp = await fetch('/api/projects/list');
if (resp.ok) {
const result = await resp.json();
if (result.success && Array.isArray(result.projects)) {
return result.projects;
}
}
} catch (e) {
console.log('Server project list not available, using localStorage:', e.message);
}
try {
return JSON.parse(localStorage.getItem('whp-sites') || '[]');
} catch (e) {
return [];
}
}
} }
} }
// -------------------------------------------------
// Delete
// -------------------------------------------------
async deleteSite(siteId) { async deleteSite(siteId) {
try { if (isWHP) {
const response = await fetch(`${this.apiUrl}?action=delete&id=${encodeURIComponent(siteId)}`); // ---- WHP mode ----
if (!response.ok) throw new Error('API returned ' + response.status); try {
const contentType = response.headers.get('content-type') || ''; const response = await fetch(`${this.apiUrl}?action=delete&site_id=${encodeURIComponent(siteId)}`, {
if (!contentType.includes('application/json')) throw new Error('Non-JSON response'); method: 'DELETE',
const result = await response.json(); headers: {
'X-CSRF-Token': WHP_CONFIG.csrfToken
}
});
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
const result = await response.json();
if (result.success) { if (result.success) {
this.showNotification('Site deleted successfully', 'success'); this.showNotification('Site deleted successfully', 'success');
return true;
} else {
throw new Error(result.error || 'Delete failed');
}
} catch (error) {
console.log('WHP API not available, trying server project delete:', error.message);
// Try server-side project storage
try {
const resp = await fetch('/api/projects/' + encodeURIComponent(siteId), { method: 'DELETE' });
if (resp.ok) {
this.showNotification('Site deleted from server', 'success');
return true; return true;
} else {
throw new Error(result.error || 'Delete failed');
} }
} catch (e) { } catch (error) {
console.log('Server project delete not available, using localStorage:', e.message); console.error('WHP delete failed:', error.message);
} this.showNotification('Delete failed: ' + error.message, 'error');
// Final fallback: localStorage
try {
const sites = JSON.parse(localStorage.getItem('whp-sites') || '[]');
const filtered = sites.filter(s => s.id !== siteId);
localStorage.setItem('whp-sites', JSON.stringify(filtered));
this.showNotification('Site deleted from local storage', 'success');
return true;
} catch (err) {
this.showNotification(`Delete failed: ${err.message}`, 'error');
return false; return false;
} }
} else {
// ---- Standalone mode ----
try {
const response = await fetch(`${this.apiUrl}?action=delete&id=${encodeURIComponent(siteId)}`);
if (!response.ok) throw new Error('API returned ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) throw new Error('Non-JSON response');
const result = await response.json();
if (result.success) {
this.showNotification('Site deleted successfully', 'success');
return true;
} else {
throw new Error(result.error || 'Delete failed');
}
} catch (error) {
console.log('WHP API not available, trying server project delete:', error.message);
try {
const resp = await fetch('/api/projects/' + encodeURIComponent(siteId), { method: 'DELETE' });
if (resp.ok) {
this.showNotification('Site deleted from server', 'success');
return true;
}
} catch (e) {
console.log('Server project delete not available, using localStorage:', e.message);
}
try {
const sites = JSON.parse(localStorage.getItem('whp-sites') || '[]');
const filtered = sites.filter(s => s.id !== siteId);
localStorage.setItem('whp-sites', JSON.stringify(filtered));
this.showNotification('Site deleted from local storage', 'success');
return true;
} catch (err) {
this.showNotification(`Delete failed: ${err.message}`, 'error');
return false;
}
}
} }
} }
showSaveDialog() { // -------------------------------------------------
const siteName = prompt('Enter a name for your site:', this.currentSiteName); // Dialogs (standalone mode only)
// -------------------------------------------------
showSaveDialog() {
// In WHP mode, save immediately without prompting
if (isWHP) {
this.saveToWHP();
return;
}
const siteName = prompt('Enter a name for your site:', this.currentSiteName);
if (siteName) { if (siteName) {
this.currentSiteName = siteName; this.currentSiteName = siteName;
this.saveToWHP(this.currentSiteId, siteName); this.saveToWHP(this.currentSiteId, siteName);
@@ -310,6 +568,12 @@ class WHPIntegration {
} }
async showLoadDialog() { async showLoadDialog() {
// In WHP mode there is no load dialog -- we always work on one site
if (isWHP) {
this.loadFromWHP();
return;
}
const sites = await this.loadSiteList(); const sites = await this.loadSiteList();
if (sites.length === 0) { if (sites.length === 0) {
@@ -317,7 +581,6 @@ class WHPIntegration {
return; return;
} }
// Create a simple modal for site selection
const modal = this.createLoadModal(sites); const modal = this.createLoadModal(sites);
document.body.appendChild(modal); document.body.appendChild(modal);
} }
@@ -380,7 +643,6 @@ class WHPIntegration {
content.innerHTML = html; content.innerHTML = html;
modal.appendChild(content); modal.appendChild(content);
// Close on background click
modal.onclick = (e) => { modal.onclick = (e) => {
if (e.target === modal) { if (e.target === modal) {
modal.remove(); modal.remove();
@@ -390,12 +652,26 @@ class WHPIntegration {
return modal; return modal;
} }
// -------------------------------------------------
// Auto-save
// -------------------------------------------------
autoSave() { autoSave() {
if (this.currentSiteId) { if (isWHP) {
this.saveToWHP(this.currentSiteId, this.currentSiteName); // In WHP mode we always have a siteId, so always auto-save
this.saveToWHP();
} else {
// Standalone: only auto-save if a site is loaded
if (this.currentSiteId) {
this.saveToWHP(this.currentSiteId, this.currentSiteName);
}
} }
} }
// -------------------------------------------------
// Notifications
// -------------------------------------------------
showNotification(message, type = 'info') { showNotification(message, type = 'info') {
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.style.cssText = ` notification.style.cssText = `
@@ -423,7 +699,6 @@ class WHPIntegration {
// Auto-initialize when GrapesJS editor is ready // Auto-initialize when GrapesJS editor is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Wait for editor to be defined
const checkEditor = setInterval(() => { const checkEditor = setInterval(() => {
if (window.editor) { if (window.editor) {
window.whpInt = new WHPIntegration(window.editor); window.whpInt = new WHPIntegration(window.editor);