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:
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -64,6 +64,16 @@ export const RADIUS_PRESETS = [
|
||||
{ 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 = [
|
||||
{ label: 'Purple Dream', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ label: 'Pink Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
RADIUS_PRESETS,
|
||||
IMAGE_RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
@@ -195,7 +195,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={RADIUS_PRESETS}
|
||||
presets={IMAGE_RADIUS_PRESETS}
|
||||
activeValue={style.borderRadius as string}
|
||||
onSelect={(v) => setPropStyle('borderRadius', v)}
|
||||
/>
|
||||
|
||||
@@ -92,6 +92,26 @@ html, body {
|
||||
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 {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
@@ -116,6 +136,7 @@ html, body {
|
||||
}
|
||||
|
||||
.panel-right {
|
||||
width: 320px;
|
||||
border-right: none;
|
||||
border-left: 1px solid #2d2d3a;
|
||||
}
|
||||
@@ -301,6 +322,52 @@ html, body {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -1650,6 +1717,7 @@ html, body {
|
||||
.asset-browser-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: min-content;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
@@ -1670,6 +1738,13 @@ html, body {
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.asset-browser-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.asset-browser-item:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: scale(1.02);
|
||||
|
||||
79
index.html
79
index.html
@@ -346,6 +346,57 @@
|
||||
</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) -->
|
||||
<div id="section-spacing" class="guided-section context-section" style="display:none;">
|
||||
<label>Padding</label>
|
||||
@@ -440,23 +491,23 @@
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<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">
|
||||
<button id="sync-nav-pages" class="guided-btn-primary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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 id="sync-nav-pages" class="guided-btn-primary" title="Auto-generate links from your site pages">
|
||||
<i class="fa fa-refresh"></i> Sync Pages
|
||||
</button>
|
||||
<button id="add-nav-link" class="guided-btn-secondary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Link
|
||||
<button id="add-nav-link" class="guided-btn-secondary" title="Add a custom link">
|
||||
<i class="fa fa-plus"></i> Page Link
|
||||
</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 id="nav-links-list" class="nav-links-list">
|
||||
<!-- Links will be populated dynamically -->
|
||||
|
||||
405
js/assets.js
405
js/assets.js
@@ -14,6 +14,9 @@
|
||||
const ASSETS_KEY = 'sitebuilder-assets';
|
||||
const DEPLOY_STATUS_KEY = 'sitebuilder-deploy-status';
|
||||
|
||||
// WHP mode detection
|
||||
const isWHP = !!window.WHP_CONFIG;
|
||||
|
||||
// Server API base URL (same origin)
|
||||
const API_BASE = '';
|
||||
|
||||
@@ -43,6 +46,12 @@
|
||||
|
||||
// -- Server availability check --
|
||||
async _checkServer() {
|
||||
if (isWHP) {
|
||||
// In WHP mode the server is always available (served by WHP)
|
||||
this.serverAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/health', { method: 'GET' });
|
||||
if (resp.ok) {
|
||||
@@ -56,6 +65,26 @@
|
||||
|
||||
// -- Persistence --
|
||||
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) {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/assets');
|
||||
@@ -168,8 +197,19 @@
|
||||
formData.append('file', file);
|
||||
|
||||
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',
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -179,20 +219,33 @@
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (data.success && data.assets && data.assets.length > 0) {
|
||||
const serverAsset = data.assets[0];
|
||||
// Add to local list
|
||||
if (data.success) {
|
||||
// WHP API returns a single asset object; standalone returns an array
|
||||
let serverAsset;
|
||||
if (data.assets && data.assets.length > 0) {
|
||||
serverAsset = data.assets[0];
|
||||
} else if (data.url) {
|
||||
serverAsset = { url: data.url, name: data.name || file.name, type: data.type || 'file' };
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Register with GrapesJS asset manager
|
||||
if (serverAsset.type === 'image') {
|
||||
this.editor.AssetManager.add({ src: serverAsset.url, name: serverAsset.name });
|
||||
}
|
||||
|
||||
return serverAsset;
|
||||
}
|
||||
}
|
||||
throw new Error('No asset returned from server');
|
||||
} catch (e) {
|
||||
console.error('Asset upload failed:', e.message);
|
||||
@@ -202,15 +255,23 @@
|
||||
}
|
||||
|
||||
_showServerRequiredError() {
|
||||
if (isWHP) {
|
||||
alert('File upload is temporarily unavailable. Please try again.');
|
||||
} else {
|
||||
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) {
|
||||
if (isWHP) {
|
||||
alert('Asset upload failed: ' + message);
|
||||
} else {
|
||||
alert('Asset upload failed: ' + message + '\n\nPlease check that server.py is running.');
|
||||
}
|
||||
}
|
||||
|
||||
addAssetUrl(url) {
|
||||
const name = url.split('/').pop().split('?')[0] || 'asset';
|
||||
@@ -234,7 +295,29 @@
|
||||
async removeAsset(id) {
|
||||
const asset = this.assets.find(a => a.id === id);
|
||||
|
||||
// If the asset is stored on the server, delete from server too
|
||||
if (isWHP) {
|
||||
// WHP mode: delete via WHP API
|
||||
if (asset) {
|
||||
try {
|
||||
const filename = asset.name || asset.url.split('/').pop();
|
||||
const resp = await fetch(
|
||||
WHP_CONFIG.apiUrl + '?action=delete_asset&site_id=' + WHP_CONFIG.siteId + '&filename=' + encodeURIComponent(filename),
|
||||
{
|
||||
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();
|
||||
@@ -248,6 +331,7 @@
|
||||
console.warn('Failed to delete asset from server:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.assets = this.assets.filter(a => a.id !== id);
|
||||
this.save();
|
||||
@@ -273,10 +357,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by type
|
||||
const groups = { image: [], video: [], css: [], js: [], file: [] };
|
||||
// Group by category
|
||||
const groups = { image: [], video: [], file: [] };
|
||||
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
|
||||
@@ -285,8 +370,8 @@
|
||||
grid.appendChild(item);
|
||||
});
|
||||
|
||||
// Render CSS/JS as list items
|
||||
[...groups.css, ...groups.js, ...groups.file].forEach(asset => {
|
||||
// Render other files as list items
|
||||
[...groups.file].forEach(asset => {
|
||||
const item = this._createListItem(asset);
|
||||
grid.appendChild(item);
|
||||
});
|
||||
@@ -297,16 +382,17 @@
|
||||
item.className = 'asset-grid-item';
|
||||
item.dataset.assetId = asset.id;
|
||||
|
||||
if (asset.type === 'image') {
|
||||
if (this._assetCategory(asset) === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.src = asset.url;
|
||||
img.alt = asset.name;
|
||||
img.alt = asset.name || '';
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
|
||||
item.appendChild(img);
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -399,6 +485,48 @@
|
||||
dropZone.style.display = 'none';
|
||||
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) {
|
||||
@@ -504,7 +632,19 @@
|
||||
if (!this.modal) return Promise.reject('No modal');
|
||||
|
||||
// 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 {
|
||||
const resp = await fetch(API_BASE + '/api/assets');
|
||||
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) {
|
||||
const grid = this.modal.querySelector('#asset-browser-grid');
|
||||
let items;
|
||||
if (type === 'all') {
|
||||
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 {
|
||||
items = this.assets.filter(a => a.type === type);
|
||||
items = this.assets.filter(a => this._assetCategory(a) === type);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
grid.innerHTML = `<div style="grid-column:1/-1;text-align:center;padding:48px 24px;color:#71717a;">
|
||||
<div style="font-size:36px;margin-bottom:12px;">📂</div>
|
||||
<div>No ${type === 'all' ? '' : type + ' '}assets yet.</div>
|
||||
<div style="font-size:12px;margin-top:4px;">Upload files above to get started.</div>
|
||||
</div>`;
|
||||
grid.textContent = '';
|
||||
const emptyZone = this._createDropZone(type);
|
||||
grid.appendChild(emptyZone);
|
||||
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 => {
|
||||
const cat = this._assetCategory(asset);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'asset-browser-item';
|
||||
|
||||
if (asset.type === 'image') {
|
||||
item.innerHTML = `<img src="${asset.url}" alt="${asset.name}" style="width:100%;height:100%;object-fit:cover;">`;
|
||||
} else if (asset.type === 'video') {
|
||||
item.innerHTML = `<div class="asset-icon"><div style="font-size:36px;">🎬</div></div>`;
|
||||
if (cat === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.src = asset.url;
|
||||
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 {
|
||||
const icon = asset.type === 'css' ? '🎨' : asset.type === 'js' ? '⚡' : '📄';
|
||||
item.innerHTML = `<div class="asset-icon"><div style="font-size:36px;">${icon}</div></div>`;
|
||||
const ext = (asset.name || '').split('.').pop().toLowerCase();
|
||||
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');
|
||||
@@ -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 --
|
||||
patchImageTraits() {
|
||||
const editor = this.editor;
|
||||
@@ -724,12 +972,97 @@
|
||||
}
|
||||
}
|
||||
|
||||
// -- Deploy Button --
|
||||
// -- Deploy Button (standalone) / Go Live button (WHP) --
|
||||
initDeployButton() {
|
||||
const navRight = document.querySelector('.nav-right');
|
||||
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 deployBtn = document.createElement('button');
|
||||
deployBtn.id = 'btn-deploy';
|
||||
|
||||
685
js/editor.js
685
js/editor.js
@@ -185,6 +185,46 @@
|
||||
|
||||
// Plugins - using global function references from CDN scripts
|
||||
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['grapesjs-preset-webpage'],
|
||||
window['grapesjs-plugin-forms'],
|
||||
@@ -342,8 +382,8 @@
|
||||
|
||||
// Navigation Menu (will be updated dynamically based on pages)
|
||||
blockManager.add('navbar', {
|
||||
label: 'Navigation',
|
||||
category: 'Layout',
|
||||
label: 'Menu',
|
||||
category: 'Basic',
|
||||
content: {
|
||||
type: 'navbar',
|
||||
tagName: 'nav',
|
||||
@@ -380,7 +420,7 @@
|
||||
components: [
|
||||
{
|
||||
tagName: 'a',
|
||||
attributes: { href: '#' },
|
||||
attributes: { href: '/' },
|
||||
style: {
|
||||
'color': '#4b5563',
|
||||
'text-decoration': 'none',
|
||||
@@ -391,7 +431,7 @@
|
||||
},
|
||||
{
|
||||
tagName: 'a',
|
||||
attributes: { href: '#' },
|
||||
attributes: { href: 'about' },
|
||||
style: {
|
||||
'color': '#4b5563',
|
||||
'text-decoration': 'none',
|
||||
@@ -402,7 +442,7 @@
|
||||
},
|
||||
{
|
||||
tagName: 'a',
|
||||
attributes: { href: '#' },
|
||||
attributes: { href: 'contact' },
|
||||
style: {
|
||||
'color': '#4b5563',
|
||||
'text-decoration': 'none',
|
||||
@@ -536,7 +576,7 @@
|
||||
// Footer
|
||||
blockManager.add('footer', {
|
||||
label: 'Footer',
|
||||
category: 'Layout',
|
||||
category: 'Basic',
|
||||
content: `<footer style="padding:40px 20px;background:#1f2937;color:#9ca3af;text-align:center;">
|
||||
<div style="max-width:1200px;margin:0 auto;">
|
||||
<div style="display:flex;justify-content:center;gap:24px;margin-bottom:20px;">
|
||||
@@ -607,7 +647,7 @@
|
||||
blockManager.add('image-block', {
|
||||
label: 'Image',
|
||||
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' }
|
||||
});
|
||||
|
||||
@@ -1828,8 +1868,11 @@
|
||||
// Store for preview page
|
||||
localStorage.setItem(STORAGE_KEY + '-preview', JSON.stringify({ html, css }));
|
||||
|
||||
// Open preview
|
||||
window.open('preview.html', '_blank');
|
||||
// Open preview (pass site_id in WHP mode so Back button works)
|
||||
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'),
|
||||
textSize: document.getElementById('section-text-size'),
|
||||
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'),
|
||||
radius: document.getElementById('section-radius'),
|
||||
thickness: document.getElementById('section-thickness'),
|
||||
@@ -2041,10 +2087,25 @@
|
||||
// Section with background - show background image controls
|
||||
sections.bgImage.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);
|
||||
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
|
||||
switch (elementType) {
|
||||
case 'text':
|
||||
@@ -2052,12 +2113,14 @@
|
||||
sections.font.style.display = 'block';
|
||||
sections.textSize.style.display = 'block';
|
||||
sections.fontWeight.style.display = 'block';
|
||||
sections.alignment.style.display = 'block';
|
||||
// Show heading level selector for headings
|
||||
const currentTag = component.get('tagName')?.toLowerCase();
|
||||
if (currentTag && currentTag.match(/^h[1-6]$/)) {
|
||||
sections.headingLevel.style.display = 'block';
|
||||
updateHeadingLevelButtons(currentTag);
|
||||
}
|
||||
updateAlignmentButtons(component);
|
||||
break;
|
||||
|
||||
case 'link':
|
||||
@@ -2071,6 +2134,8 @@
|
||||
sections.font.style.display = 'block';
|
||||
sections.textSize.style.display = 'block';
|
||||
sections.fontWeight.style.display = 'block';
|
||||
sections.alignment.style.display = 'block';
|
||||
updateAlignmentButtons(component);
|
||||
// Load current link values
|
||||
loadLinkValues(component);
|
||||
break;
|
||||
@@ -2084,14 +2149,23 @@
|
||||
sections.bgColor.style.display = 'block';
|
||||
sections.bgGradient.style.display = 'block';
|
||||
sections.bgImage.style.display = 'block';
|
||||
sections.alignment.style.display = 'block';
|
||||
sections.spacing.style.display = 'block';
|
||||
sections.radius.style.display = 'block';
|
||||
loadBgImageValues(component);
|
||||
updateAlignmentButtons(component);
|
||||
break;
|
||||
|
||||
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.radius.style.display = 'block';
|
||||
updateAlignmentButtons(component);
|
||||
break;
|
||||
|
||||
case 'form':
|
||||
@@ -2296,48 +2370,185 @@
|
||||
function loadNavLinks(component) {
|
||||
if (!component) return;
|
||||
|
||||
// Find the nav-links container within the nav
|
||||
const linksContainer = component.components().find(c => {
|
||||
const classes = c.getClasses();
|
||||
return classes.includes('nav-links');
|
||||
});
|
||||
|
||||
const linksContainer = component.components().find(c => c.getClasses().includes('nav-links'));
|
||||
if (!linksContainer) {
|
||||
navLinksList.innerHTML = '<p class="no-links-msg">No links container found</p>';
|
||||
navLinksList.textContent = 'No links container found';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all link components
|
||||
const links = linksContainer.components().filter(c => c.get('tagName')?.toLowerCase() === 'a');
|
||||
navLinksList.textContent = '';
|
||||
|
||||
// Clear and rebuild list
|
||||
navLinksList.innerHTML = '';
|
||||
if (links.length === 0) {
|
||||
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) => {
|
||||
const item = document.createElement('div');
|
||||
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');
|
||||
textSpan.className = 'nav-link-text';
|
||||
textSpan.textContent = link.getEl()?.textContent || 'Link';
|
||||
const isCta = link.getClasses().includes('nav-cta');
|
||||
const href = link.getAttributes().href || '#';
|
||||
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');
|
||||
deleteBtn.className = 'nav-link-delete';
|
||||
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.style.cssText = 'background:none;border:none;color:#ef4444;cursor:pointer;padding:2px 4px;font-size:14px;line-height:1;';
|
||||
deleteBtn.textContent = '\u00d7';
|
||||
deleteBtn.title = 'Remove link';
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
link.remove();
|
||||
loadNavLinks(component);
|
||||
});
|
||||
|
||||
item.appendChild(textSpan);
|
||||
item.appendChild(deleteBtn);
|
||||
navLinksList.appendChild(item);
|
||||
if (isCta) {
|
||||
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);
|
||||
});
|
||||
|
||||
if (links.length === 0) {
|
||||
navLinksList.innerHTML = '<p class="no-links-msg">No links in navigation</p>';
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
document.querySelectorAll('.spacing-preset').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -2577,102 +3028,92 @@
|
||||
// ==========================================
|
||||
|
||||
// Sync navigation with pages
|
||||
syncNavPagesBtn.addEventListener('click', () => {
|
||||
const selected = editor.getSelected();
|
||||
if (!selected || !isNavigation(selected)) return;
|
||||
|
||||
// Find the nav-links container
|
||||
const linksContainer = selected.components().find(c => {
|
||||
const classes = c.getClasses();
|
||||
return classes.includes('nav-links');
|
||||
// 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;
|
||||
});
|
||||
|
||||
if (!linksContainer) {
|
||||
alert('Navigation structure not recognized. Please use the Navigation block.');
|
||||
return;
|
||||
return { container: linksContainer, insertIndex };
|
||||
}
|
||||
|
||||
// Get CTA button if exists (keep it)
|
||||
const existingLinks = linksContainer.components();
|
||||
let ctaLink = null;
|
||||
existingLinks.forEach(link => {
|
||||
const classes = link.getClasses();
|
||||
if (classes.includes('nav-cta')) {
|
||||
ctaLink = link.clone();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear existing links
|
||||
linksContainer.components().reset();
|
||||
|
||||
// Add links for each page
|
||||
pages.forEach((page, index) => {
|
||||
linksContainer.components().add({
|
||||
tagName: 'a',
|
||||
attributes: { href: page.slug === 'index' ? '#' : `#${page.slug}` },
|
||||
style: {
|
||||
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', () => {
|
||||
const selected = editor.getSelected();
|
||||
if (!selected || !isNavigation(selected)) return;
|
||||
const nav = getNavLinksContainer(selected);
|
||||
if (!nav) { alert('Navigation structure not recognized.'); return; }
|
||||
|
||||
// Preserve CTA
|
||||
let ctaLink = null;
|
||||
nav.container.components().forEach(link => {
|
||||
if (link.getClasses().includes('nav-cta')) ctaLink = link.clone();
|
||||
});
|
||||
|
||||
nav.container.components().reset();
|
||||
|
||||
// Add a link for each page using clean slugs (no .html)
|
||||
pages.forEach(page => {
|
||||
const href = page.slug === 'index' ? '/' : page.slug;
|
||||
nav.container.components().add({
|
||||
tagName: 'a',
|
||||
attributes: { href },
|
||||
style: { ...defaultLinkStyle },
|
||||
content: page.name
|
||||
});
|
||||
});
|
||||
|
||||
// Re-add CTA if existed
|
||||
if (ctaLink) {
|
||||
linksContainer.components().add(ctaLink);
|
||||
}
|
||||
|
||||
// Refresh the links list UI
|
||||
if (ctaLink) nav.container.components().add(ctaLink);
|
||||
loadNavLinks(selected);
|
||||
});
|
||||
|
||||
// Add new link to navigation
|
||||
// Add page link
|
||||
addNavLinkBtn.addEventListener('click', () => {
|
||||
const selected = editor.getSelected();
|
||||
if (!selected || !isNavigation(selected)) return;
|
||||
const nav = getNavLinksContainer(selected);
|
||||
if (!nav) return;
|
||||
|
||||
// Find the nav-links container
|
||||
const linksContainer = selected.components().find(c => {
|
||||
const classes = c.getClasses();
|
||||
return classes.includes('nav-links');
|
||||
});
|
||||
|
||||
if (!linksContainer) {
|
||||
alert('Navigation structure not recognized. Please use the Navigation block.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find position to insert (before CTA if exists)
|
||||
const links = linksContainer.components();
|
||||
let insertIndex = links.length;
|
||||
|
||||
links.forEach((link, index) => {
|
||||
const classes = link.getClasses();
|
||||
if (classes.includes('nav-cta')) {
|
||||
insertIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
// Add new link
|
||||
linksContainer.components().add({
|
||||
nav.container.components().add({
|
||||
tagName: 'a',
|
||||
attributes: { href: '#' },
|
||||
style: {
|
||||
'color': '#4b5563',
|
||||
'text-decoration': 'none',
|
||||
'font-size': '15px',
|
||||
'font-family': 'Inter, sans-serif'
|
||||
},
|
||||
attributes: { href: '/' },
|
||||
style: { ...defaultLinkStyle },
|
||||
content: 'New Link'
|
||||
}, { at: insertIndex });
|
||||
}, { at: nav.insertIndex });
|
||||
|
||||
// Refresh the links list UI
|
||||
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
|
||||
// ==========================================
|
||||
@@ -2808,7 +3249,7 @@
|
||||
const styles = component.getStyle();
|
||||
|
||||
// Reset all active states
|
||||
document.querySelectorAll('.color-preset, .size-preset, .spacing-preset, .radius-preset, .thickness-preset, .font-preset, .weight-preset, .gradient-preset')
|
||||
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'));
|
||||
|
||||
// Set active text color
|
||||
@@ -3611,14 +4052,50 @@
|
||||
currentPageId: currentPageId
|
||||
}));
|
||||
|
||||
// Open preview
|
||||
window.open('preview.html', '_blank');
|
||||
// Open preview (pass site_id in WHP mode so Back button works)
|
||||
const previewUrl = (window.WHP_CONFIG)
|
||||
? 'preview.html?site_id=' + WHP_CONFIG.siteId
|
||||
: 'preview.html';
|
||||
window.open(previewUrl, '_blank');
|
||||
});
|
||||
|
||||
// Make editor accessible globally for debugging
|
||||
window.editor = editor;
|
||||
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)
|
||||
// ==========================================
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
/**
|
||||
* 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 {
|
||||
constructor(editor, apiUrl = '/api/site-builder.php') {
|
||||
constructor(editor, apiUrl) {
|
||||
this.editor = editor;
|
||||
this.apiUrl = apiUrl;
|
||||
|
||||
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() {
|
||||
// Add save/load buttons to the editor
|
||||
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);
|
||||
|
||||
// Load site list on startup
|
||||
// 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 to WHP" button
|
||||
// Add "Save" button
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.id = 'btn-whp-save';
|
||||
saveBtn.className = 'nav-btn primary';
|
||||
@@ -37,9 +62,27 @@ class WHPIntegration {
|
||||
</svg>
|
||||
<span>Save</span>
|
||||
`;
|
||||
saveBtn.onclick = () => this.showSaveDialog();
|
||||
|
||||
// Add "Load from WHP" button
|
||||
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';
|
||||
@@ -52,24 +95,154 @@ class WHPIntegration {
|
||||
`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 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,
|
||||
@@ -80,7 +253,6 @@ class WHPIntegration {
|
||||
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',
|
||||
@@ -112,6 +284,7 @@ class WHPIntegration {
|
||||
return this._saveToLocalStorage(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _saveToLocalStorage(data) {
|
||||
// Try server-side project storage first
|
||||
@@ -136,11 +309,9 @@ class WHPIntegration {
|
||||
|
||||
// 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;
|
||||
@@ -164,7 +335,31 @@ class WHPIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 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);
|
||||
@@ -182,6 +377,7 @@ class WHPIntegration {
|
||||
this._loadFromLocalStorage(siteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_applySiteData(site) {
|
||||
if (site.grapesjs) {
|
||||
@@ -190,9 +386,9 @@ class WHPIntegration {
|
||||
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');
|
||||
this.currentSiteId = site.id || this.currentSiteId;
|
||||
this.currentSiteName = site.name || this.currentSiteName;
|
||||
this.showNotification(`Loaded "${this.currentSiteName}" successfully!`, 'success');
|
||||
}
|
||||
|
||||
async _loadFromLocalStorage(siteId) {
|
||||
@@ -224,7 +420,31 @@ class WHPIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 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);
|
||||
@@ -239,7 +459,6 @@ class WHPIntegration {
|
||||
}
|
||||
} 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) {
|
||||
@@ -251,7 +470,6 @@ class WHPIntegration {
|
||||
} 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) {
|
||||
@@ -259,8 +477,40 @@ class WHPIntegration {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 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);
|
||||
@@ -276,7 +526,6 @@ class WHPIntegration {
|
||||
}
|
||||
} 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) {
|
||||
@@ -286,7 +535,6 @@ class WHPIntegration {
|
||||
} 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);
|
||||
@@ -299,10 +547,20 @@ class WHPIntegration {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Dialogs (standalone mode only)
|
||||
// -------------------------------------------------
|
||||
|
||||
showSaveDialog() {
|
||||
const siteName = prompt('Enter a name for your site:', this.currentSiteName);
|
||||
// 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);
|
||||
@@ -310,6 +568,12 @@ class WHPIntegration {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -317,7 +581,6 @@ class WHPIntegration {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a simple modal for site selection
|
||||
const modal = this.createLoadModal(sites);
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
@@ -380,7 +643,6 @@ class WHPIntegration {
|
||||
content.innerHTML = html;
|
||||
modal.appendChild(content);
|
||||
|
||||
// Close on background click
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
@@ -390,11 +652,25 @@ class WHPIntegration {
|
||||
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');
|
||||
@@ -423,7 +699,6 @@ class WHPIntegration {
|
||||
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user