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>
461 lines
17 KiB
JavaScript
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);
|