Files
site-builder/js/whp-integration.js
Josh Knapp 606c9b78c8 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>
2026-04-27 05:18:31 -07:00

736 lines
28 KiB
JavaScript

/**
* WHP Integration for Site Builder
* 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 {
constructor(editor, apiUrl) {
this.editor = editor;
if (isWHP) {
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();
}
init() {
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
setInterval(() => this.autoSave(), 30000);
// In standalone mode, load the site list on startup.
// In WHP mode we always work on one site so no list is needed.
if (!isWHP) {
this.loadSiteList();
}
}
// -------------------------------------------------
// Toolbar buttons
// -------------------------------------------------
addToolbarButtons() {
// Add "Save" button
const saveBtn = document.createElement('button');
saveBtn.id = 'btn-whp-save';
saveBtn.className = 'nav-btn primary';
saveBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
<span>Save</span>
`;
if (isWHP) {
// In WHP mode, save immediately (site name comes from WHP)
saveBtn.onclick = () => this.saveToWHP();
} else {
// Standalone: prompt for a name
saveBtn.onclick = () => this.showSaveDialog();
}
if (isWHP) {
// In WHP mode we only show the Save button (no Load button)
const exportBtn = document.getElementById('btn-export');
if (exportBtn && exportBtn.parentNode) {
exportBtn.parentNode.insertBefore(saveBtn, exportBtn);
const divider = document.createElement('span');
divider.className = 'divider';
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) {
const html = this.editor.getHtml();
const css = this.editor.getCss();
const grapesjs = this.editor.getProjectData();
if (isWHP) {
// ---- WHP mode ----
// Save current page content first
if (typeof saveCurrentPageContent === 'function') {
saveCurrentPageContent();
}
// 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 no pages tracked, fall back to single-page mode
if (pagesData.length === 0) {
pagesData.push({
filename: 'index.html',
title: WHP_CONFIG.siteName || 'Home',
html: html,
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);
}
}
}
async _saveToLocalStorage(data) {
// Try server-side project storage first
try {
const resp = await fetch('/api/projects/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (resp.ok) {
const result = await resp.json();
if (result.success) {
this.currentSiteId = data.id;
this.currentSiteName = data.name;
this.showNotification(`Saved "${data.name}" to server!`, 'success');
return { id: data.id, name: data.name };
}
}
} catch (e) {
console.log('Server project save not available, trying localStorage:', e.message);
}
// Fall back to localStorage with size check
try {
const sitesJson = localStorage.getItem('whp-sites') || '[]';
const sites = JSON.parse(sitesJson);
const idx = sites.findIndex(s => s.id === data.id);
if (idx >= 0) {
sites[idx] = data;
} else {
sites.push(data);
}
localStorage.setItem('whp-sites', JSON.stringify(sites));
this.currentSiteId = data.id;
this.currentSiteName = data.name;
this.showNotification(`Saved "${data.name}" to local storage!`, 'success');
return { id: data.id, name: data.name };
} catch (err) {
if (err.name === 'QuotaExceededError' || err.message.includes('quota')) {
this.showNotification('Storage full! Start server.py for unlimited storage.', 'error');
} else {
this.showNotification(`Save failed: ${err.message}`, 'error');
}
console.error('localStorage save error:', err);
}
}
// -------------------------------------------------
// Load
// -------------------------------------------------
async loadFromWHP(siteId) {
if (isWHP) {
// ---- WHP mode ----
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);
}
}
}
_applySiteData(site) {
if (site.grapesjs) {
this.editor.loadProjectData(site.grapesjs);
} else {
this.editor.setComponents(site.html || '');
this.editor.setStyle(site.css || '');
}
this.currentSiteId = site.id || this.currentSiteId;
this.currentSiteName = site.name || this.currentSiteName;
this.showNotification(`Loaded "${this.currentSiteName}" successfully!`, 'success');
}
async _loadFromLocalStorage(siteId) {
// Try server-side project storage first
try {
const resp = await fetch('/api/projects/' + encodeURIComponent(siteId));
if (resp.ok) {
const result = await resp.json();
if (result.success && result.project) {
this._applySiteData(result.project);
return;
}
}
} catch (e) {
console.log('Server project load not available, trying localStorage:', e.message);
}
// Fall back to localStorage
try {
const sites = JSON.parse(localStorage.getItem('whp-sites') || '[]');
const site = sites.find(s => s.id === siteId);
if (site) {
this._applySiteData(site);
} else {
this.showNotification('Site not found in local storage', 'error');
}
} catch (err) {
this.showNotification(`Load failed: ${err.message}`, 'error');
}
}
// -------------------------------------------------
// Site list
// -------------------------------------------------
async loadSiteList() {
if (isWHP) {
// ---- WHP 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.error('WHP loadSiteList failed:', error.message);
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) {
if (isWHP) {
// ---- WHP mode ----
try {
const response = await fetch(`${this.apiUrl}?action=delete&site_id=${encodeURIComponent(siteId)}`, {
method: 'DELETE',
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) {
this.showNotification('Site deleted successfully', 'success');
return true;
} else {
throw new Error(result.error || 'Delete failed');
}
} catch (error) {
console.error('WHP delete failed:', error.message);
this.showNotification('Delete failed: ' + error.message, 'error');
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;
}
}
}
}
// -------------------------------------------------
// 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) {
this.currentSiteName = siteName;
this.saveToWHP(this.currentSiteId, siteName);
}
}
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();
if (sites.length === 0) {
alert('No saved sites found.');
return;
}
const modal = this.createLoadModal(sites);
document.body.appendChild(modal);
}
createLoadModal(sites) {
const modal = document.createElement('div');
modal.className = 'whp-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 8px;
padding: 24px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
`;
let html = '<h2 style="margin-top: 0;">Load Site</h2>';
html += '<div style="display: grid; gap: 12px;">';
sites.forEach(site => {
const date = new Date(site.modified * 1000).toLocaleString();
html += `
<div style="border: 1px solid #ddd; padding: 12px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${site.name}</strong><br>
<small style="color: #666;">Modified: ${date}</small>
</div>
<div>
<button onclick="window.whpInt.loadFromWHP('${site.id}'); this.closest('.whp-modal').remove();"
style="padding: 6px 12px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 8px;">
Load
</button>
<button onclick="if(confirm('Delete this site?')) { window.whpInt.deleteSite('${site.id}').then(() => this.closest('.whp-modal').remove()); }"
style="padding: 6px 12px; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer;">
Delete
</button>
</div>
</div>
`;
});
html += '</div>';
html += '<button onclick="this.closest(\'.whp-modal\').remove()" style="margin-top: 16px; padding: 8px 16px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer;">Close</button>';
content.innerHTML = html;
modal.appendChild(content);
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove();
}
};
return modal;
}
// -------------------------------------------------
// Auto-save
// -------------------------------------------------
autoSave() {
if (isWHP) {
// 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') {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
padding: 16px 24px;
background: ${type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : '#0066cc'};
color: white;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
z-index: 10001;
animation: slideIn 0.3s ease-out;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
}
// Auto-initialize when GrapesJS editor is ready
document.addEventListener('DOMContentLoaded', () => {
const checkEditor = setInterval(() => {
if (window.editor) {
window.whpInt = new WHPIntegration(window.editor);
clearInterval(checkEditor);
}
}, 100);
});
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);