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

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

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

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

View File

@@ -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,19 +219,32 @@
}
const data = await resp.json();
if (data.success && data.assets && data.assets.length > 0) {
const serverAsset = data.assets[0];
// Add to local list
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 });
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' };
}
return serverAsset;
if (serverAsset) {
// Normalize type for GrapesJS
const mimeType = serverAsset.type || '';
if (mimeType.startsWith('image/') || ['image','jpg','jpeg','png','gif','webp','svg'].includes(mimeType)) {
serverAsset.type = 'image';
}
this.assets.push(serverAsset);
this.save();
this.renderAssetsPanel();
if (serverAsset.type === 'image') {
this.editor.AssetManager.add({ src: serverAsset.url, name: serverAsset.name });
}
return serverAsset;
}
}
throw new Error('No asset returned from server');
} catch (e) {
@@ -202,14 +255,22 @@
}
_showServerRequiredError() {
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);
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) {
alert('Asset upload failed: ' + message + '\n\nPlease check that server.py is running.');
if (isWHP) {
alert('Asset upload failed: ' + message);
} else {
alert('Asset upload failed: ' + message + '\n\nPlease check that server.py is running.');
}
}
addAssetUrl(url) {
@@ -234,18 +295,41 @@
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 (asset && this.serverAvailable && asset.url && asset.url.startsWith('/storage/assets/')) {
try {
const filename = asset.id || asset.filename || asset.url.split('/').pop();
const resp = await fetch(API_BASE + '/api/assets/' + encodeURIComponent(filename), {
method: 'DELETE'
});
if (!resp.ok) {
console.warn('Server-side asset deletion failed');
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();
const resp = await fetch(API_BASE + '/api/assets/' + encodeURIComponent(filename), {
method: 'DELETE'
});
if (!resp.ok) {
console.warn('Server-side asset deletion failed');
}
} catch (e) {
console.warn('Failed to delete asset from server:', e.message);
}
} catch (e) {
console.warn('Failed to delete asset from server:', e.message);
}
}
@@ -273,10 +357,11 @@
return;
}
// 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';