Files
site-builder/js/whp-integration.js
Josh Knapp a71b58c2c7 Initial commit: Site Builder with PHP API backend
Visual drag-and-drop website builder using GrapesJS with:
- Multi-page editor with live preview
- File-based asset storage via PHP API (no localStorage base64)
- Template library, Docker support, and Playwright test suite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:25:42 +00:00

461 lines
17 KiB
JavaScript

/**
* WHP Integration for Site Builder
* Provides save/load functionality via WHP API
*/
class WHPIntegration {
constructor(editor, apiUrl = '/api/site-builder.php') {
this.editor = editor;
this.apiUrl = apiUrl;
this.currentSiteId = null;
this.currentSiteName = 'Untitled Site';
this.init();
}
init() {
// Add save/load buttons to the editor
this.addToolbarButtons();
// Auto-save every 30 seconds
setInterval(() => this.autoSave(), 30000);
// Load site list on startup
this.loadSiteList();
}
addToolbarButtons() {
// Add "Save to WHP" 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>
`;
saveBtn.onclick = () => this.showSaveDialog();
// Add "Load from WHP" button
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();
// Insert buttons before export button
const exportBtn = document.getElementById('btn-export');
if (exportBtn && exportBtn.parentNode) {
exportBtn.parentNode.insertBefore(loadBtn, exportBtn);
exportBtn.parentNode.insertBefore(saveBtn, exportBtn);
// Add divider
const divider = document.createElement('span');
divider.className = 'divider';
exportBtn.parentNode.insertBefore(divider, exportBtn);
}
}
async saveToWHP(siteId = null, siteName = null) {
const html = this.editor.getHtml();
const css = this.editor.getCss();
const grapesjs = this.editor.getProjectData();
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 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();
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 {
// Load existing sites list
const sitesJson = localStorage.getItem('whp-sites') || '[]';
const sites = JSON.parse(sitesJson);
// Update or add
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);
}
}
async loadFromWHP(siteId) {
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.currentSiteName = site.name;
this.showNotification(`Loaded "${site.name}" 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');
}
}
async loadSiteList() {
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 server-side project storage
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);
}
// Final fallback: localStorage
try {
return JSON.parse(localStorage.getItem('whp-sites') || '[]');
} catch (e) {
return [];
}
}
}
async deleteSite(siteId) {
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 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;
}
} catch (e) {
console.log('Server project delete not available, using localStorage:', e.message);
}
// 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;
}
}
}
showSaveDialog() {
const siteName = prompt('Enter a name for your site:', this.currentSiteName);
if (siteName) {
this.currentSiteName = siteName;
this.saveToWHP(this.currentSiteId, siteName);
}
}
async showLoadDialog() {
const sites = await this.loadSiteList();
if (sites.length === 0) {
alert('No saved sites found.');
return;
}
// Create a simple modal for site selection
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);
// Close on background click
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove();
}
};
return modal;
}
autoSave() {
if (this.currentSiteId) {
this.saveToWHP(this.currentSiteId, this.currentSiteName);
}
}
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', () => {
// Wait for editor to be defined
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);